ritesh19180 commited on
Commit
cb54bf7
·
verified ·
1 Parent(s): 117cde0

Upload folder using huggingface_hub

Browse files
Files changed (50) hide show
  1. .gitattributes +0 -23
  2. .gitignore +0 -7
  3. Dockerfile +19 -23
  4. Frontend/eslint.config.js +0 -21
  5. Frontend/package-lock.json +0 -0
  6. Frontend/package.json +2 -16
  7. Frontend/src/App.jsx +117 -117
  8. Frontend/src/admin/components/AdminSidebar.jsx +1 -3
  9. Frontend/src/admin/components/SLABadge.jsx +25 -14
  10. Frontend/src/admin/components/TicketTable.jsx +3 -13
  11. Frontend/src/admin/pages/AdminDashboard.jsx +2 -8
  12. Frontend/src/admin/pages/AdminSettings.jsx +2 -111
  13. Frontend/src/admin/pages/AdminTicketDetail.jsx +1 -24
  14. Frontend/src/admin/pages/AdminTickets.jsx +37 -36
  15. Frontend/src/components/shared/BugReportWidget.jsx +12 -0
  16. Frontend/src/config.js +1 -2
  17. Frontend/src/pages/AdminSignup.jsx +3 -18
  18. Frontend/src/pages/LandingPage.jsx +41 -44
  19. Frontend/src/pages/Signup.jsx +4 -19
  20. Frontend/src/services/aiAssistant.js +69 -88
  21. Frontend/src/services/api.js +19 -50
  22. Frontend/src/store/authStore.js +2 -59
  23. Frontend/src/store/ticketStore.js +0 -21
  24. Frontend/src/user/components/RecentTickets.jsx +0 -5
  25. Frontend/src/user/pages/AutoResolveChat.jsx +2 -56
  26. Frontend/src/user/pages/CreateTicket.jsx +1 -1
  27. Frontend/src/user/pages/MyTickets.jsx +0 -13
  28. Frontend/src/user/pages/TicketDetail.jsx +0 -26
  29. Frontend/vite.config.js +1 -1
  30. MobileApp/src/screens/auth/LoginScreen.js +0 -2
  31. MobileApp/src/screens/user/ProfileScreen.js +0 -3
  32. README.md +1 -3
  33. backend/.env.example +13 -101
  34. backend/Dockerfile +19 -23
  35. backend/main.py +301 -662
  36. backend/requirements.txt +1 -7
  37. backend/services/classifier_service.py +29 -21
  38. backend/services/classifier_v2.py +68 -88
  39. backend/services/classifier_v3.py +58 -78
  40. backend/services/duplicate_service.py +2 -27
  41. backend/services/gemini_service.py +0 -63
  42. backend/services/metrics_service.py +31 -0
  43. backend/services/ner_service.py +4 -16
  44. backend/services/rag_service.py +1 -17
  45. backend/services/sla_service.py +3 -39
  46. deploy/monitoring/grafana_dashboard.json +241 -0
  47. requirements.txt +1 -7
  48. supabase/functions/send-user-approval-email/index.ts +56 -131
  49. supabase/migrations/20260522080000_add_sla_escalation.sql +1 -1
  50. supabase/migrations/20260531_add_company_settings.sql +0 -60
.gitattributes CHANGED
@@ -2,29 +2,6 @@
2
  *.pt filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
  *.pkl filter=lfs diff=lfs merge=lfs -text
5
- .tmp-ci-venv/Lib/site-packages/dateutil/zoneinfo/dateutil-zoneinfo.tar.gz filter=lfs diff=lfs merge=lfs -text
6
- .tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/t64-arm.exe filter=lfs diff=lfs merge=lfs -text
7
- .tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/t64.exe filter=lfs diff=lfs merge=lfs -text
8
- .tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/w64-arm.exe filter=lfs diff=lfs merge=lfs -text
9
- .tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/w64.exe filter=lfs diff=lfs merge=lfs -text
10
- .tmp-ci-venv/Lib/site-packages/setuptools/cli-arm64.exe filter=lfs diff=lfs merge=lfs -text
11
- .tmp-ci-venv/Lib/site-packages/setuptools/gui-arm64.exe filter=lfs diff=lfs merge=lfs -text
12
- .tmp-ci-venv/Scripts/dotenv.exe filter=lfs diff=lfs merge=lfs -text
13
- .tmp-ci-venv/Scripts/fastapi.exe filter=lfs diff=lfs merge=lfs -text
14
- .tmp-ci-venv/Scripts/httpx.exe filter=lfs diff=lfs merge=lfs -text
15
- .tmp-ci-venv/Scripts/idna.exe filter=lfs diff=lfs merge=lfs -text
16
- .tmp-ci-venv/Scripts/markdown-it.exe filter=lfs diff=lfs merge=lfs -text
17
- .tmp-ci-venv/Scripts/normalizer.exe filter=lfs diff=lfs merge=lfs -text
18
- .tmp-ci-venv/Scripts/pip.exe filter=lfs diff=lfs merge=lfs -text
19
- .tmp-ci-venv/Scripts/pip3.11.exe filter=lfs diff=lfs merge=lfs -text
20
- .tmp-ci-venv/Scripts/pip3.exe filter=lfs diff=lfs merge=lfs -text
21
- .tmp-ci-venv/Scripts/pygmentize.exe filter=lfs diff=lfs merge=lfs -text
22
- .tmp-ci-venv/Scripts/pyiceberg.exe filter=lfs diff=lfs merge=lfs -text
23
- .tmp-ci-venv/Scripts/python.exe filter=lfs diff=lfs merge=lfs -text
24
- .tmp-ci-venv/Scripts/pythonw.exe filter=lfs diff=lfs merge=lfs -text
25
- .tmp-ci-venv/Scripts/uvicorn.exe filter=lfs diff=lfs merge=lfs -text
26
- .tmp-ci-venv/Scripts/watchfiles.exe filter=lfs diff=lfs merge=lfs -text
27
- .tmp-ci-venv/Scripts/websockets.exe filter=lfs diff=lfs merge=lfs -text
28
  Frontend/public/favicon.jpeg filter=lfs diff=lfs merge=lfs -text
29
  Frontend/public/favicon.png filter=lfs diff=lfs merge=lfs -text
30
  Frontend/public/team/pragati_tiwari.jpg filter=lfs diff=lfs merge=lfs -text
 
2
  *.pt filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
  *.pkl filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  Frontend/public/favicon.jpeg filter=lfs diff=lfs merge=lfs -text
6
  Frontend/public/favicon.png filter=lfs diff=lfs merge=lfs -text
7
  Frontend/public/team/pragati_tiwari.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -34,10 +34,3 @@ docs/logs/
34
  scratch/
35
  .npm-cache/
36
 
37
- # GSSoC Score Tracking and Temporary Scripts
38
- scratch/
39
- .npm-cache/
40
- *.tmp
41
- ../MOBILE_SETUP_TEMP.md
42
- ../CI_YML_TEMP.yml
43
- ../ENV_EXAMPLE_TEMP.example
 
34
  scratch/
35
  .npm-cache/
36
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -1,40 +1,36 @@
1
- # syntax=docker/dockerfile:1
 
2
 
3
- FROM python:3.10-slim AS builder
4
 
5
- WORKDIR /build
6
- RUN apt-get update && apt-get install -y --no-install-recommends \
 
 
 
 
 
7
  git \
8
  && rm -rf /var/lib/apt/lists/*
9
 
 
10
  COPY requirements.txt .
11
- RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
12
-
13
- FROM python:3.10-slim AS runtime
14
 
15
- LABEL org.opencontainers.image.title="helpdesk-backend"
16
- LABEL org.opencontainers.image.description="HELPDESK.AI FastAPI backend (multi-stage)"
17
 
18
- WORKDIR /app
19
-
20
- RUN apt-get update && apt-get install -y --no-install-recommends \
21
- libgl1 \
22
- libglib2.0-0 \
23
- && rm -rf /var/lib/apt/lists/* \
24
- && useradd --create-home --shell /usr/sbin/nologin appuser
25
-
26
- COPY --from=builder /install /usr/local
27
  COPY . /app/backend
28
 
29
- ENV PYTHONPATH=/app \
30
- PYTHONDONTWRITEBYTECODE=1 \
31
- PYTHONUNBUFFERED=1
32
 
 
33
  EXPOSE 7860
34
 
35
  HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
36
  CMD ["python", "backend/healthcheck.py"]
37
 
38
- USER appuser
39
-
40
  CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.10-slim
3
 
4
+ LABEL version="1.1.1" rebuild_trigger="2026-03-08-2032"
5
 
6
+ # Set the working directory to /app
7
+ WORKDIR /app
8
+
9
+ # Install system dependencies required for EasyOCR and OpenCV
10
+ RUN apt-get update && apt-get install -y \
11
+ libgl1 \
12
+ libglib2.0-0 \
13
  git \
14
  && rm -rf /var/lib/apt/lists/*
15
 
16
+ # Copy the requirements file into the container
17
  COPY requirements.txt .
 
 
 
18
 
19
+ # Install dependencies (no-cache-dir keeps the docker image smaller)
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
 
22
+ # Copy all the remaining files into the container as a 'backend' directory
23
+ # This allows absolute imports like 'from backend.services...' to work perfectly
 
 
 
 
 
 
 
24
  COPY . /app/backend
25
 
26
+ # Tell Python where to look for modules (so it can find the 'backend' folder)
27
+ ENV PYTHONPATH=/app
 
28
 
29
+ # Expose port 7860 (Hugging Face Spaces default)
30
  EXPOSE 7860
31
 
32
  HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
33
  CMD ["python", "backend/healthcheck.py"]
34
 
35
+ # Run the FastAPI server via Uvicorn
 
36
  CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
Frontend/eslint.config.js CHANGED
@@ -35,25 +35,4 @@ export default defineConfig([
35
  'react-refresh/only-export-components': 'off',
36
  },
37
  },
38
- {
39
- files: [
40
- '**/*.{test,spec}.{js,jsx}',
41
- '**/__tests__/**/*.{js,jsx}',
42
- 'jest.setup.js',
43
- 'jest.fileMock.js',
44
- ],
45
- languageOptions: {
46
- globals: {
47
- ...globals.browser,
48
- ...globals.node,
49
- ...globals.jest,
50
- },
51
- },
52
- },
53
- {
54
- files: ['jest.fileMock.js'],
55
- languageOptions: {
56
- sourceType: 'commonjs',
57
- },
58
- },
59
  ])
 
35
  'react-refresh/only-export-components': 'off',
36
  },
37
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ])
Frontend/package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
Frontend/package.json CHANGED
@@ -7,11 +7,9 @@
7
  "dev": "vite",
8
  "build": "vite build",
9
  "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10
- "check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
11
  "preview": "vite preview",
12
  "prepare": "husky install",
13
- "format": "prettier --write .",
14
- "test": "jest"
15
  },
16
  "dependencies": {
17
  "@google/generative-ai": "^0.24.1",
@@ -44,18 +42,11 @@
44
  "zustand": "^5.0.11"
45
  },
46
  "devDependencies": {
47
- "@babel/core": "^7.24.0",
48
- "@babel/preset-env": "^7.24.0",
49
- "@babel/preset-react": "^7.24.0",
50
  "@eslint/js": "^9.39.1",
51
- "@testing-library/dom": "^10.4.1",
52
- "@testing-library/jest-dom": "^6.6.3",
53
- "@testing-library/react": "^16.2.0",
54
  "@types/react": "^19.2.7",
55
  "@types/react-dom": "^19.2.3",
56
  "@vitejs/plugin-react": "^5.1.1",
57
  "autoprefixer": "^10.4.24",
58
- "babel-jest": "^29.7.0",
59
  "eslint": "^9.39.2",
60
  "eslint-config-prettier": "^10.1.8",
61
  "eslint-plugin-react": "^7.37.5",
@@ -63,16 +54,11 @@
63
  "eslint-plugin-react-refresh": "^0.4.24",
64
  "globals": "^16.5.0",
65
  "husky": "^9.1.7",
66
- "identity-obj-proxy": "^3.0.0",
67
- "jest": "^29.7.0",
68
- "jest-environment-jsdom": "^29.7.0",
69
  "lint-staged": "^16.2.7",
70
  "postcss": "^8.5.6",
71
  "prettier": "^3.8.1",
72
  "tailwindcss": "^3.4.19",
73
  "vite": "^7.3.1"
74
  },
75
- "lint-staged": {
76
- "*.{js,jsx}": ["eslint --fix", "prettier --write"]
77
- }
78
  }
 
7
  "dev": "vite",
8
  "build": "vite build",
9
  "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
 
10
  "preview": "vite preview",
11
  "prepare": "husky install",
12
+ "format": "prettier --write ."
 
13
  },
14
  "dependencies": {
15
  "@google/generative-ai": "^0.24.1",
 
42
  "zustand": "^5.0.11"
43
  },
44
  "devDependencies": {
 
 
 
45
  "@eslint/js": "^9.39.1",
 
 
 
46
  "@types/react": "^19.2.7",
47
  "@types/react-dom": "^19.2.3",
48
  "@vitejs/plugin-react": "^5.1.1",
49
  "autoprefixer": "^10.4.24",
 
50
  "eslint": "^9.39.2",
51
  "eslint-config-prettier": "^10.1.8",
52
  "eslint-plugin-react": "^7.37.5",
 
54
  "eslint-plugin-react-refresh": "^0.4.24",
55
  "globals": "^16.5.0",
56
  "husky": "^9.1.7",
 
 
 
57
  "lint-staged": "^16.2.7",
58
  "postcss": "^8.5.6",
59
  "prettier": "^3.8.1",
60
  "tailwindcss": "^3.4.19",
61
  "vite": "^7.3.1"
62
  },
63
+ "lint-staged": "{'*.{js,jsx}': ['eslint --fix', 'prettier --write']}"
 
 
64
  }
Frontend/src/App.jsx CHANGED
@@ -5,81 +5,80 @@ import {
5
  Navigate,
6
  useLocation
7
  } from "react-router-dom";
8
- import React, { Suspense, useEffect, lazy } from "react";
 
 
9
  import useTicketStore from "./store/ticketStore";
 
 
10
  import useRealtimeNotifications from "./hooks/useRealtimeNotifications";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  import AdminProtectedRoute from "./components/shared/AdminProtectedRoute";
12
  import MasterAdminProtectedRoute from "./components/shared/MasterAdminProtectedRoute";
13
  import ProtectedRoute from "./components/shared/ProtectedRoute";
14
  import useAuthStore from "./store/authStore";
15
  import NotApproved from "./pages/NotApproved";
16
- const Login = lazy(() => import("./pages/Login"));
17
- const ForgotPassword = lazy(() => import("./pages/ForgotPassword"));
18
- const ResetPassword = lazy(() => import("./pages/ResetPassword"));
19
- const Signup = lazy(() => import("./pages/Signup"));
20
- const AdminSignup = lazy(() => import("./pages/AdminSignup"));
21
- const AdminLobby = lazy(() => import("./pages/AdminLobby"));
22
- const UserLobby = lazy(() => import("./pages/UserLobby"));
23
- const LandingPage = lazy(() => import("./pages/LandingPage"));
24
- const ContactSales = lazy(() => import("./pages/ContactSales"));
25
-
26
- const DuplicateDetection = lazy(() => import("./user/pages/DuplicateDetection"));
27
- const AutoResolveChat = lazy(() => import("./user/pages/AutoResolveChat"));
28
- const Resolved = lazy(() => import("./user/pages/Resolved"));
29
- const TicketTracking = lazy(() => import("./user/pages/TicketTracking"));
30
-
31
- const UserLayout = lazy(() => import("./user/UserLayout"));
32
- const AdminLayout = lazy(() => import("./admin/layout/AdminLayout"));
33
-
34
- const Dashboard = lazy(() => import("./user/pages/Dashboard"));
35
- const CreateTicket = lazy(() => import("./user/pages/CreateTicket"));
36
- const MyTickets = lazy(() => import("./user/pages/MyTickets"));
37
- const TicketResult = lazy(() => import("./user/pages/TicketResult"));
38
- const Profile = lazy(() => import("./user/pages/Profile"));
39
- const TicketDetail = lazy(() => import("./user/pages/TicketDetail"));
40
- const AIProcessing = lazy(() => import("./user/pages/AIProcessing"));
41
- const AIUnderstanding = lazy(() => import("./user/pages/AIUnderstanding"));
42
- const Notifications = lazy(() => import("./user/pages/Notifications"));
43
- const Help = lazy(() => import("./user/pages/Help"));
44
-
45
- const AdminDashboard = lazy(() => import("./admin/pages/AdminDashboard"));
46
- const AdminTickets = lazy(() => import("./admin/pages/AdminTickets"));
47
- const AdminTicketDetail = lazy(() => import("./admin/pages/AdminTicketDetail"));
48
- const AdminUsers = lazy(() => import("./admin/pages/AdminUsers"));
49
- const AdminAnalytics = lazy(() => import("./admin/pages/AdminAnalytics"));
50
- const AdminProfile = lazy(() => import("./admin/pages/AdminProfile"));
51
- const AdminSettings = lazy(() => import("./admin/pages/AdminSettings"));
52
- const SLAPage = lazy(() => import("./admin/pages/SLAPage"));
53
- const MasterBugReports = lazy(() => import("./master-admin/pages/MasterBugReports"));
54
-
55
- const AutoCategorizationFeature = lazy(() => import("./pages/features/AutoCategorizationFeature"));
56
- const PriorityDetectionFeature = lazy(() => import("./pages/features/PriorityDetectionFeature"));
57
- const SmartResolutionFeature = lazy(() => import("./pages/features/SmartResolutionFeature"));
58
-
59
- const TermsOfService = lazy(() => import("./pages/legal/TermsOfService"));
60
- const PrivacyPolicy = lazy(() => import("./pages/legal/PrivacyPolicy"));
61
- const Security = lazy(() => import("./pages/legal/Security"));
62
-
63
- const MasterAdminLogin = lazy(() => import("./pages/MasterAdminLogin"));
64
- const MasterAdminLayout = lazy(() => import("./master-admin/layout/MasterAdminLayout"));
65
- const MasterAdminDashboard = lazy(() => import("./master-admin/pages/MasterAdminDashboard"));
66
- const PendingAdminRequests = lazy(() => import("./master-admin/pages/PendingAdminRequests"));
67
- const AllCompanies = lazy(() => import("./master-admin/pages/AllCompanies"));
68
- const AllAdmins = lazy(() => import("./master-admin/pages/AllAdmins"));
69
- const Changelog = lazy(() => import("./pages/Changelog"));
70
- const NotFoundPage = lazy(() => import("./components/ui/not-found-2").then((module) => ({ default: module.NotFound })));
71
- const Toaster = lazy(() => import("./components/shared/Toaster"));
72
- const BugReportWidget = lazy(() => import("./components/shared/BugReportWidget"));
73
-
74
- function RouteFallback() {
75
- return (
76
- <div className="flex min-h-[40vh] items-center justify-center px-6 py-16">
77
- <div className="rounded-2xl border border-slate-200 bg-white px-6 py-4 text-sm font-semibold text-slate-500 shadow-sm">
78
- Loading...
79
- </div>
80
- </div>
81
- );
82
- }
83
 
84
 
85
  function TitleUpdater() {
@@ -97,7 +96,6 @@ function TitleUpdater() {
97
  else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
98
  else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
99
  else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
100
- else if (path.startsWith('/admin/sla')) title = 'SLA Monitor | Admin';
101
  // Master Admin Routes
102
  else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
103
  else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
@@ -193,11 +191,10 @@ function AppLayout() {
193
  <Route path="/admin/analytics" element={<AdminAnalytics />} />
194
  <Route path="/admin/profile" element={<AdminProfile />} />
195
  <Route path="/admin/settings" element={<AdminSettings />} />
196
- <Route path="/admin/sla" element={<SLAPage />} />
197
  </Route>
198
  </Route>
199
 
200
- <Route path="*" element={<NotFoundPage />} />
201
  </Routes>
202
  </>
203
  );
@@ -209,60 +206,63 @@ function App() {
209
 
210
  useEffect(() => {
211
  initialize();
 
 
 
 
 
212
  }, [initialize]);
213
 
214
  return (
215
  <BrowserRouter>
216
  <TitleUpdater />
217
  <ScrollToTop />
218
- <Suspense fallback={<RouteFallback />}>
219
- <Toaster />
220
- <BugReportWidget />
221
- <Routes>
222
- {/* Public */}
223
- <Route path="/" element={<LandingPage />} />
224
- <Route path="/login" element={<Login />} />
225
- <Route path="/forgot-password" element={<ForgotPassword />} />
226
- <Route path="/reset-password" element={<ResetPassword />} />
227
- <Route path="/signup" element={<Signup />} />
228
- <Route path="/admin-signup" element={<AdminSignup />} />
229
- <Route path="/admin-lobby" element={<AdminLobby />} />
230
- <Route path="/user-lobby" element={<UserLobby />} />
231
- <Route path="/not-approved" element={<NotApproved />} />
232
- <Route path="/contact-sales" element={<ContactSales />} />
233
-
234
- {/* Feature Pages */}
235
- <Route path="/features/categorization" element={<AutoCategorizationFeature />} />
236
- <Route path="/features/priority" element={<PriorityDetectionFeature />} />
237
- <Route path="/features/resolution" element={<SmartResolutionFeature />} />
238
-
239
- {/* Resources Pages */}
240
- <Route path="/changelog" element={<Changelog />} />
241
-
242
- {/* Legal Pages */}
243
- <Route path="/terms" element={<TermsOfService />} />
244
- <Route path="/privacy" element={<PrivacyPolicy />} />
245
- <Route path="/security" element={<Security />} />
246
-
247
- {/* Master Admin Portal */}
248
- <Route path="/master-admin-login" element={<MasterAdminLogin />} />
249
-
250
- <Route element={<MasterAdminProtectedRoute />}>
251
- <Route element={<MasterAdminLayout />}>
252
- <Route path="/master-admin/dashboard" element={<MasterAdminDashboard />} />
253
- <Route path="/master-admin/admin-requests" element={<PendingAdminRequests />} />
254
- <Route path="/master-admin/companies" element={<AllCompanies />} />
255
- <Route path="/master-admin/all-admins" element={<AllAdmins />} />
256
- <Route path="/master-admin/bug-reports" element={<MasterBugReports />} />
257
- </Route>
258
  </Route>
 
259
 
260
- {/* Protected */}
261
- <Route element={<ProtectedRoute />}>
262
- <Route path="/*" element={<AppLayout />} />
263
- </Route>
264
- </Routes>
265
- </Suspense>
266
  </BrowserRouter>
267
  );
268
  }
 
5
  Navigate,
6
  useLocation
7
  } from "react-router-dom";
8
+ import React, { useEffect } from "react";
9
+ import { AnimatePresence } from "framer-motion";
10
+ import { NotFound } from "./components/ui/not-found-2";
11
  import useTicketStore from "./store/ticketStore";
12
+ import Toaster from "./components/shared/Toaster";
13
+ import BugReportWidget from "./components/shared/BugReportWidget";
14
  import useRealtimeNotifications from "./hooks/useRealtimeNotifications";
15
+
16
+ // Auth Components
17
+ import Login from "./pages/Login";
18
+ import ForgotPassword from "./pages/ForgotPassword";
19
+ import ResetPassword from "./pages/ResetPassword";
20
+ import Signup from "./pages/Signup";
21
+ import AdminSignup from "./pages/AdminSignup";
22
+ import AdminLobby from "./pages/AdminLobby";
23
+ import UserLobby from "./pages/UserLobby";
24
+ import LandingPage from "./pages/LandingPage";
25
+ import ContactSales from "./pages/ContactSales";
26
+
27
+ // Legacy components
28
+ import DuplicateDetection from "./user/pages/DuplicateDetection";
29
+ import AutoResolveChat from "./user/pages/AutoResolveChat";
30
+ import Resolved from "./user/pages/Resolved";
31
+ import TicketTracking from "./user/pages/TicketTracking";
32
+ // Layouts
33
+ import UserLayout from "./user/UserLayout";
34
+ import AdminLayout from "./admin/layout/AdminLayout";
35
+
36
+ // User Pages
37
+ import Dashboard from "./user/pages/Dashboard";
38
+ import CreateTicket from "./user/pages/CreateTicket";
39
+ import MyTickets from "./user/pages/MyTickets";
40
+ import TicketResult from "./user/pages/TicketResult";
41
+ import Profile from "./user/pages/Profile";
42
+ import TicketDetail from "./user/pages/TicketDetail";
43
+ import TicketProcessing from "./user/pages/AIProcessing"; // Renamed generic import just in case, but keeping AIProcessing
44
+ import AIProcessing from "./user/pages/AIProcessing";
45
+ import AIUnderstanding from "./user/pages/AIUnderstanding";
46
+ import Notifications from "./user/pages/Notifications";
47
+ import Help from "./user/pages/Help";
48
+
49
+ // NEW Admin Pages (Refactored)
50
+ import AdminDashboard from "./admin/pages/AdminDashboard";
51
+ import AdminTickets from "./admin/pages/AdminTickets";
52
+ import AdminTicketDetail from "./admin/pages/AdminTicketDetail";
53
+ import AdminUsers from "./admin/pages/AdminUsers";
54
+ import AdminAnalytics from "./admin/pages/AdminAnalytics";
55
+ import AdminProfile from "./admin/pages/AdminProfile";
56
+ import AdminSettings from "./admin/pages/AdminSettings";
57
+ import MasterBugReports from "./master-admin/pages/MasterBugReports";
58
+
59
+ // Feature Pages
60
+ import AutoCategorizationFeature from "./pages/features/AutoCategorizationFeature";
61
+ import PriorityDetectionFeature from "./pages/features/PriorityDetectionFeature";
62
+ import SmartResolutionFeature from "./pages/features/SmartResolutionFeature";
63
+
64
+ // Legal Pages
65
+ import TermsOfService from "./pages/legal/TermsOfService";
66
+ import PrivacyPolicy from "./pages/legal/PrivacyPolicy";
67
+ import Security from "./pages/legal/Security";
68
  import AdminProtectedRoute from "./components/shared/AdminProtectedRoute";
69
  import MasterAdminProtectedRoute from "./components/shared/MasterAdminProtectedRoute";
70
  import ProtectedRoute from "./components/shared/ProtectedRoute";
71
  import useAuthStore from "./store/authStore";
72
  import NotApproved from "./pages/NotApproved";
73
+
74
+ // Master Admin Components
75
+ import MasterAdminLogin from "./pages/MasterAdminLogin";
76
+ import MasterAdminLayout from "./master-admin/layout/MasterAdminLayout";
77
+ import MasterAdminDashboard from "./master-admin/pages/MasterAdminDashboard";
78
+ import PendingAdminRequests from "./master-admin/pages/PendingAdminRequests";
79
+ import AllCompanies from "./master-admin/pages/AllCompanies";
80
+ import AllAdmins from "./master-admin/pages/AllAdmins";
81
+ import Changelog from "./pages/Changelog";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
 
84
  function TitleUpdater() {
 
96
  else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
97
  else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
98
  else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
 
99
  // Master Admin Routes
100
  else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
101
  else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
 
191
  <Route path="/admin/analytics" element={<AdminAnalytics />} />
192
  <Route path="/admin/profile" element={<AdminProfile />} />
193
  <Route path="/admin/settings" element={<AdminSettings />} />
 
194
  </Route>
195
  </Route>
196
 
197
+ <Route path="*" element={<NotFound />} />
198
  </Routes>
199
  </>
200
  );
 
206
 
207
  useEffect(() => {
208
  initialize();
209
+ // Dark mode initialize
210
+ const saved = localStorage.getItem('theme');
211
+ if (saved === 'dark') {
212
+ document.documentElement.classList.add('dark');
213
+ }
214
  }, [initialize]);
215
 
216
  return (
217
  <BrowserRouter>
218
  <TitleUpdater />
219
  <ScrollToTop />
220
+ <Toaster />
221
+ <BugReportWidget />
222
+ <Routes>
223
+ {/* Public */}
224
+ <Route path="/" element={<LandingPage />} />
225
+ <Route path="/login" element={<Login />} />
226
+ <Route path="/forgot-password" element={<ForgotPassword />} />
227
+ <Route path="/reset-password" element={<ResetPassword />} />
228
+ <Route path="/signup" element={<Signup />} />
229
+ <Route path="/admin-signup" element={<AdminSignup />} />
230
+ <Route path="/admin-lobby" element={<AdminLobby />} />
231
+ <Route path="/user-lobby" element={<UserLobby />} />
232
+ <Route path="/not-approved" element={<NotApproved />} />
233
+ <Route path="/contact-sales" element={<ContactSales />} />
234
+
235
+ {/* Feature Pages */}
236
+ <Route path="/features/categorization" element={<AutoCategorizationFeature />} />
237
+ <Route path="/features/priority" element={<PriorityDetectionFeature />} />
238
+ <Route path="/features/resolution" element={<SmartResolutionFeature />} />
239
+
240
+ {/* Resources Pages */}
241
+ <Route path="/changelog" element={<Changelog />} />
242
+
243
+ {/* Legal Pages */}
244
+ <Route path="/terms" element={<TermsOfService />} />
245
+ <Route path="/privacy" element={<PrivacyPolicy />} />
246
+ <Route path="/security" element={<Security />} />
247
+
248
+ {/* Master Admin Portal */}
249
+ <Route path="/master-admin-login" element={<MasterAdminLogin />} />
250
+
251
+ <Route element={<MasterAdminProtectedRoute />}>
252
+ <Route element={<MasterAdminLayout />}>
253
+ <Route path="/master-admin/dashboard" element={<MasterAdminDashboard />} />
254
+ <Route path="/master-admin/admin-requests" element={<PendingAdminRequests />} />
255
+ <Route path="/master-admin/companies" element={<AllCompanies />} />
256
+ <Route path="/master-admin/all-admins" element={<AllAdmins />} />
257
+ <Route path="/master-admin/bug-reports" element={<MasterBugReports />} />
 
 
258
  </Route>
259
+ </Route>
260
 
261
+ {/* Protected */}
262
+ <Route element={<ProtectedRoute />}>
263
+ <Route path="/*" element={<AppLayout />} />
264
+ </Route>
265
+ </Routes>
 
266
  </BrowserRouter>
267
  );
268
  }
Frontend/src/admin/components/AdminSidebar.jsx CHANGED
@@ -10,8 +10,7 @@ import {
10
  LogOut,
11
  Activity,
12
  ChevronLeft,
13
- ChevronRight,
14
- Clock
15
  } from 'lucide-react';
16
  import useAuthStore from '../../store/authStore';
17
 
@@ -22,7 +21,6 @@ const AdminSidebar = ({ isMobile, onClose, isCollapsed, onToggleCollapse }) => {
22
  { label: 'Users', path: '/admin/users', icon: Users },
23
  { label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
24
  { label: 'Profile', path: '/admin/profile', icon: UserCircle },
25
- { label: 'SLA Monitor', path: '/admin/sla', icon: Clock },
26
  ];
27
 
28
  const { logout } = useAuthStore();
 
10
  LogOut,
11
  Activity,
12
  ChevronLeft,
13
+ ChevronRight
 
14
  } from 'lucide-react';
15
  import useAuthStore from '../../store/authStore';
16
 
 
21
  { label: 'Users', path: '/admin/users', icon: Users },
22
  { label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
23
  { label: 'Profile', path: '/admin/profile', icon: UserCircle },
 
24
  ];
25
 
26
  const { logout } = useAuthStore();
Frontend/src/admin/components/SLABadge.jsx CHANGED
@@ -1,12 +1,12 @@
1
  import React, { useState, useEffect } from 'react';
2
  import { Clock, AlertTriangle, ShieldCheck } from 'lucide-react';
3
 
4
- // SLA time limits in milliseconds based on priority
5
  const SLA_LIMITS = {
6
- critical: 2 * 60 * 60 * 1000, // 2 hours
7
- high: 4 * 60 * 60 * 1000, // 4 hours
8
- medium: 8 * 60 * 60 * 1000, // 8 hours
9
- low: 24 * 60 * 60 * 1000, // 24 hours
10
  };
11
 
12
  function formatDuration(ms) {
@@ -19,36 +19,47 @@ function formatDuration(ms) {
19
  }
20
 
21
  /**
22
- * SLABadge — shows SLA status for a ticket based on its priority and creation time.
23
  *
24
  * Props:
25
  * - priority: string ('critical' | 'high' | 'medium' | 'low')
26
  * - createdAt: string (ISO date string)
 
 
27
  * - status: string — if ticket is resolved/closed, show "Met" without countdown
28
  * - compact: bool — if true, shows just the badge with no label text
29
  */
30
- export default function SLABadge({ priority, createdAt, status, compact = false }) {
31
  const [remaining, setRemaining] = useState(null);
32
 
33
- const isResolved = ['resolved', 'closed', 'auto-resolved'].includes(status?.toLowerCase());
 
 
34
 
35
  useEffect(() => {
36
- if (isResolved || !priority || !createdAt) return;
 
 
 
 
37
 
38
- const priorityKey = priority.toLowerCase();
39
  const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
40
- const createdMs = new Date(createdAt).getTime();
 
 
 
 
41
 
42
  const calculate = () => {
43
- const elapsed = Date.now() - createdMs;
44
- const rem = limit - elapsed;
45
  setRemaining(rem);
46
  };
47
 
48
  calculate();
49
  const timer = setInterval(calculate, 60 * 1000); // update every minute
50
  return () => clearInterval(timer);
51
- }, [priority, createdAt, isResolved]);
52
 
53
  if (isResolved) {
54
  return (
 
1
  import React, { useState, useEffect } from 'react';
2
  import { Clock, AlertTriangle, ShieldCheck } from 'lucide-react';
3
 
4
+ // SLA resolution limits in milliseconds based on priority.
5
  const SLA_LIMITS = {
6
+ critical: 4 * 60 * 60 * 1000,
7
+ high: 12 * 60 * 60 * 1000,
8
+ medium: 24 * 60 * 60 * 1000,
9
+ low: 72 * 60 * 60 * 1000,
10
  };
11
 
12
  function formatDuration(ms) {
 
19
  }
20
 
21
  /**
22
+ * SLABadge — shows SLA status for a ticket.
23
  *
24
  * Props:
25
  * - priority: string ('critical' | 'high' | 'medium' | 'low')
26
  * - createdAt: string (ISO date string)
27
+ * - slaBreachAt: string (ISO date string) — preferred persisted deadline
28
+ * - slaStatus: string ('ACTIVE' | 'WARNING' | 'BREACHED')
29
  * - status: string — if ticket is resolved/closed, show "Met" without countdown
30
  * - compact: bool — if true, shows just the badge with no label text
31
  */
32
+ export default function SLABadge({ priority, createdAt, slaBreachAt, slaStatus, status, compact = false }) {
33
  const [remaining, setRemaining] = useState(null);
34
 
35
+ const normalizedStatus = status?.toLowerCase();
36
+ const normalizedSlaStatus = slaStatus?.toUpperCase();
37
+ const isResolved = ['resolved', 'closed', 'auto-resolved', 'auto resolved'].includes(normalizedStatus);
38
 
39
  useEffect(() => {
40
+ if (isResolved) return;
41
+ if (normalizedSlaStatus === 'BREACHED') {
42
+ setRemaining(-1);
43
+ return;
44
+ }
45
 
46
+ const priorityKey = priority?.toLowerCase?.() || 'medium';
47
  const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
48
+ const deadlineMs = slaBreachAt
49
+ ? new Date(slaBreachAt).getTime()
50
+ : new Date(createdAt).getTime() + limit;
51
+
52
+ if (!Number.isFinite(deadlineMs)) return;
53
 
54
  const calculate = () => {
55
+ const rem = deadlineMs - Date.now();
 
56
  setRemaining(rem);
57
  };
58
 
59
  calculate();
60
  const timer = setInterval(calculate, 60 * 1000); // update every minute
61
  return () => clearInterval(timer);
62
+ }, [priority, createdAt, slaBreachAt, normalizedSlaStatus, isResolved]);
63
 
64
  if (isResolved) {
65
  return (
Frontend/src/admin/components/TicketTable.jsx CHANGED
@@ -73,9 +73,6 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
73
  ? ticket.assigned_team
74
  : (teamMap[effectiveCategory] || ticket.assigned_team || 'L1 Helpdesk');
75
  const statusSt = getStatusStyle(ticket.status);
76
- const translationMeta = ticket?.metadata?.translation;
77
- const isTranslated = Boolean(translationMeta?.translated);
78
- const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
79
 
80
  // Truncated subject
81
  const subject = ticket.subject || ticket.summary || 'Untitled ticket';
@@ -132,16 +129,9 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
132
  <span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
133
  {truncSubject}
134
  </span>
135
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
136
- <span style={{ fontSize: '11px', color: '#6b7280' }}>
137
- {effectiveCategory || 'General'}
138
- </span>
139
- {isTranslated && (
140
- <span style={{ fontSize: '10px', color: '#0369a1' }}>
141
- Translated from {sourceLanguageName}
142
- </span>
143
- )}
144
- </div>
145
  </div>
146
  </div>
147
  </td>
 
73
  ? ticket.assigned_team
74
  : (teamMap[effectiveCategory] || ticket.assigned_team || 'L1 Helpdesk');
75
  const statusSt = getStatusStyle(ticket.status);
 
 
 
76
 
77
  // Truncated subject
78
  const subject = ticket.subject || ticket.summary || 'Untitled ticket';
 
129
  <span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
130
  {truncSubject}
131
  </span>
132
+ <span style={{ fontSize: '11px', color: '#6b7280' }}>
133
+ {effectiveCategory || 'General'}
134
+ </span>
 
 
 
 
 
 
 
135
  </div>
136
  </div>
137
  </td>
Frontend/src/admin/pages/AdminDashboard.jsx CHANGED
@@ -4,7 +4,6 @@ import { Activity, AlertTriangle, Clock, ShieldCheck } from 'lucide-react';
4
 
5
  import useAuthStore from "../../store/authStore";
6
  import { supabase } from "../../lib/supabaseClient";
7
- import useTicketsRealtime from "../../hooks/useTicketsRealtime";
8
  import StatCard from "../components/StatCard";
9
  import TicketTable from "../components/TicketTable";
10
  import { formatTimelineDate } from "../../utils/dateUtils";
@@ -108,13 +107,6 @@ const AdminDashboard = () => {
108
  const [isLoading, setIsLoading] = React.useState(true);
109
  const [nowMs, setNowMs] = React.useState(() => Date.now());
110
 
111
- useTicketsRealtime({
112
- company: profile?.company,
113
- enabled: Boolean(profile),
114
- onTicketsChange: setTickets,
115
- channelName: 'admin_dashboard_tickets_realtime',
116
- });
117
-
118
  React.useEffect(() => {
119
  if (profile) {
120
  const fetchStats = async () => {
@@ -143,6 +135,8 @@ const AdminDashboard = () => {
143
  };
144
 
145
  fetchStats();
 
 
146
  }
147
  }, [profile]);
148
 
 
4
 
5
  import useAuthStore from "../../store/authStore";
6
  import { supabase } from "../../lib/supabaseClient";
 
7
  import StatCard from "../components/StatCard";
8
  import TicketTable from "../components/TicketTable";
9
  import { formatTimelineDate } from "../../utils/dateUtils";
 
107
  const [isLoading, setIsLoading] = React.useState(true);
108
  const [nowMs, setNowMs] = React.useState(() => Date.now());
109
 
 
 
 
 
 
 
 
110
  React.useEffect(() => {
111
  if (profile) {
112
  const fetchStats = async () => {
 
135
  };
136
 
137
  fetchStats();
138
+ const interval = setInterval(fetchStats, 30000);
139
+ return () => clearInterval(interval);
140
  }
141
  }, [profile]);
142
 
Frontend/src/admin/pages/AdminSettings.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
  import {
3
  Settings,
4
  Cpu,
@@ -8,14 +8,6 @@ import {
8
  ShieldCheck
9
  } from 'lucide-react';
10
  import useAdminStore from '../store/adminStore';
11
- import useAuthStore from '../../store/authStore';
12
- import { supabase } from '../../lib/supabaseClient';
13
- import {
14
- DEFAULT_ADMIN_SETTINGS,
15
- resolveCompanyId,
16
- settingsFromSystemSettingsRow,
17
- settingsToSystemSettingsRow
18
- } from '../../utils/adminSettingsPersistence';
19
  import { Card, CardContent } from "../../components/ui/card";
20
  import { Select } from "../../components/ui/select";
21
 
@@ -25,97 +17,12 @@ import { Select } from "../../components/ui/select";
25
  */
26
  const AdminSettings = () => {
27
  const { settings, updateSettings } = useAdminStore();
28
- const { user, profile } = useAuthStore();
29
- const [isLoadingSettings, setIsLoadingSettings] = useState(false);
30
- const [isSavingSettings, setIsSavingSettings] = useState(false);
31
- const [statusMessage, setStatusMessage] = useState('');
32
- const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
33
-
34
- const companyId = useMemo(() => resolveCompanyId(profile, user), [profile, user]);
35
-
36
- useEffect(() => {
37
- let isMounted = true;
38
-
39
- const loadCompanySettings = async () => {
40
- if (!companyId) {
41
- setStatusMessage('Company profile is required before settings can be synced.');
42
- return;
43
- }
44
-
45
- setIsLoadingSettings(true);
46
- setStatusMessage('');
47
-
48
- const { data, error } = await supabase
49
- .from('system_settings')
50
- .select('ai_confidence_threshold, duplicate_sensitivity, enable_auto_resolve, auto_close_days, email_notifications, admin_alerts')
51
- .eq('company_id', companyId)
52
- .maybeSingle();
53
-
54
- if (!isMounted) return;
55
-
56
- if (error) {
57
- setStatusMessage(`Unable to load saved settings: ${error.message}`);
58
- } else if (data) {
59
- updateSettings(settingsFromSystemSettingsRow(data, DEFAULT_ADMIN_SETTINGS));
60
- setHasUnsavedChanges(false);
61
- setStatusMessage('Saved company settings loaded.');
62
- }
63
-
64
- setIsLoadingSettings(false);
65
- };
66
-
67
- loadCompanySettings();
68
-
69
- return () => {
70
- isMounted = false;
71
- };
72
- }, [companyId, updateSettings]);
73
-
74
- const saveCompanySettings = useCallback(async (nextSettings, { silent = false } = {}) => {
75
- if (!companyId) {
76
- setStatusMessage('Company profile is required before settings can be saved.');
77
- return;
78
- }
79
-
80
- setIsSavingSettings(true);
81
- if (!silent) {
82
- setStatusMessage('');
83
- }
84
-
85
- const { error } = await supabase
86
- .from('system_settings')
87
- .upsert(settingsToSystemSettingsRow(nextSettings, companyId), { onConflict: 'company_id' });
88
-
89
- if (error) {
90
- setStatusMessage(`Unable to save settings: ${error.message}`);
91
- } else {
92
- setHasUnsavedChanges(false);
93
- setStatusMessage('Settings saved for this company.');
94
- }
95
-
96
- setIsSavingSettings(false);
97
- }, [companyId]);
98
 
 
99
  const handleChange = (key, value) => {
100
  updateSettings({ [key]: value });
101
- setHasUnsavedChanges(true);
102
- setStatusMessage('Saving changes...');
103
  };
104
 
105
- const handleSaveSettings = useCallback(() => {
106
- saveCompanySettings(settings);
107
- }, [saveCompanySettings, settings]);
108
-
109
- useEffect(() => {
110
- if (!hasUnsavedChanges || isLoadingSettings || isSavingSettings) return undefined;
111
-
112
- const saveTimer = window.setTimeout(() => {
113
- saveCompanySettings(settings, { silent: true });
114
- }, 800);
115
-
116
- return () => window.clearTimeout(saveTimer);
117
- }, [hasUnsavedChanges, isLoadingSettings, isSavingSettings, saveCompanySettings, settings]);
118
-
119
  return (
120
  <div className="max-w-4xl mx-auto py-6 space-y-10 pb-20 animate-in fade-in duration-700">
121
  {/* 1. Header Area */}
@@ -128,22 +35,6 @@ const AdminSettings = () => {
128
  <ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
129
  </p>
130
  </div>
131
- <div className="flex flex-col items-start md:items-end gap-2">
132
- <button
133
- type="button"
134
- onClick={handleSaveSettings}
135
- disabled={!companyId || isLoadingSettings || isSavingSettings || !hasUnsavedChanges}
136
- className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-xs font-black uppercase tracking-widest text-white shadow-xl shadow-slate-200 transition-all hover:bg-indigo-600 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
137
- >
138
- <Save size={16} />
139
- {isSavingSettings ? 'Saving...' : 'Save Now'}
140
- </button>
141
- {statusMessage && (
142
- <p className="max-w-xs text-left md:text-right text-[10px] font-bold uppercase tracking-widest text-slate-400">
143
- {statusMessage}
144
- </p>
145
- )}
146
- </div>
147
  </div>
148
 
149
  <div className="space-y-8">
 
1
+ import React from 'react';
2
  import {
3
  Settings,
4
  Cpu,
 
8
  ShieldCheck
9
  } from 'lucide-react';
10
  import useAdminStore from '../store/adminStore';
 
 
 
 
 
 
 
 
11
  import { Card, CardContent } from "../../components/ui/card";
12
  import { Select } from "../../components/ui/select";
13
 
 
17
  */
18
  const AdminSettings = () => {
19
  const { settings, updateSettings } = useAdminStore();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ // Handlers
22
  const handleChange = (key, value) => {
23
  updateSettings({ [key]: value });
 
 
24
  };
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  return (
27
  <div className="max-w-4xl mx-auto py-6 space-y-10 pb-20 animate-in fade-in duration-700">
28
  {/* 1. Header Area */}
 
35
  <ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
36
  </p>
37
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  </div>
39
 
40
  <div className="space-y-8">
Frontend/src/admin/pages/AdminTicketDetail.jsx CHANGED
@@ -16,7 +16,6 @@ import { formatTicketId } from "../../utils/format";
16
  import SLABadge from "../components/SLABadge";
17
  import { formatFullTimestamp } from "../../utils/dateUtils";
18
  import TicketTimeline from "../../user/components/TicketTimeline";
19
- import TicketAuditTimeline from "../components/TicketAuditTimeline";
20
 
21
  const AdminTicketDetail = () => {
22
  const { ticket_id } = useParams();
@@ -35,7 +34,6 @@ const AdminTicketDetail = () => {
35
  const [imageUrl, setImageUrl] = useState(null);
36
  const [isUpdating, setIsUpdating] = useState(null);
37
  const [isLive, setIsLive] = useState(false);
38
- const [showOriginalText, setShowOriginalText] = useState(false);
39
 
40
  const [correctionForm, setCorrectionForm] = useState({
41
  category: '',
@@ -199,11 +197,6 @@ const AdminTicketDetail = () => {
199
  const displayPriority = ticket.priority || 'Medium';
200
  const displaySummary = ticket.summary || ticket.subject || 'No Summary';
201
  const displayText = ticket.description || ticket.text || displaySummary;
202
- const translationMeta = ticket.metadata?.translation;
203
- const originalTextMeta = ticket.metadata?.original_text;
204
- const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
205
- const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
206
- const renderedText = showOriginalText && isTranslated ? originalTextMeta.description : displayText;
207
 
208
  return (
209
  <div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
@@ -319,22 +312,8 @@ const AdminTicketDetail = () => {
319
  <span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
320
  </div>
321
  <div style={{ padding: '28px' }}>
322
- {isTranslated && (
323
- <div style={{ marginBottom: '16px', border: '1px solid #bae6fd', background: '#f0f9ff', borderRadius: '12px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
324
- <span style={{ fontSize: '12px', fontWeight: 600, color: '#0c4a6e' }}>
325
- Translated from {sourceLanguageName}
326
- </span>
327
- <button
328
- type="button"
329
- onClick={() => setShowOriginalText(prev => !prev)}
330
- style={{ fontSize: '11px', fontWeight: 700, color: '#0369a1', background: 'transparent', border: 'none', cursor: 'pointer' }}
331
- >
332
- {showOriginalText ? 'View English' : 'View Original'}
333
- </button>
334
- </div>
335
- )}
336
  <div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
337
- "{renderedText}"
338
  </div>
339
 
340
  {imageUrl && (
@@ -363,8 +342,6 @@ const AdminTicketDetail = () => {
363
  </div>
364
  <TicketTimeline ticket={ticket} />
365
  </div>
366
-
367
- <TicketAuditTimeline ticketId={ticket.id} companyId={ticket.company_id} />
368
  </div>
369
 
370
  {/* AI Column */}
 
16
  import SLABadge from "../components/SLABadge";
17
  import { formatFullTimestamp } from "../../utils/dateUtils";
18
  import TicketTimeline from "../../user/components/TicketTimeline";
 
19
 
20
  const AdminTicketDetail = () => {
21
  const { ticket_id } = useParams();
 
34
  const [imageUrl, setImageUrl] = useState(null);
35
  const [isUpdating, setIsUpdating] = useState(null);
36
  const [isLive, setIsLive] = useState(false);
 
37
 
38
  const [correctionForm, setCorrectionForm] = useState({
39
  category: '',
 
197
  const displayPriority = ticket.priority || 'Medium';
198
  const displaySummary = ticket.summary || ticket.subject || 'No Summary';
199
  const displayText = ticket.description || ticket.text || displaySummary;
 
 
 
 
 
200
 
201
  return (
202
  <div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
 
312
  <span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
313
  </div>
314
  <div style={{ padding: '28px' }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  <div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
316
+ "{displayText}"
317
  </div>
318
 
319
  {imageUrl && (
 
342
  </div>
343
  <TicketTimeline ticket={ticket} />
344
  </div>
 
 
345
  </div>
346
 
347
  {/* AI Column */}
Frontend/src/admin/pages/AdminTickets.jsx CHANGED
@@ -1,9 +1,8 @@
1
- import React, { useCallback, useState, useMemo, useEffect } from 'react';
2
  import { useNavigate, useLocation } from 'react-router-dom';
3
  import useAuthStore from "../../store/authStore";
4
  import useToastStore from "../../store/toastStore";
5
  import { supabase } from "../../lib/supabaseClient";
6
- import useTicketsRealtime from "../../hooks/useTicketsRealtime";
7
  import {
8
  Search,
9
  Filter,
@@ -30,7 +29,7 @@ import { formatTimelineDate } from "../../utils/dateUtils";
30
  const AdminTickets = () => {
31
  const navigate = useNavigate();
32
  const location = useLocation();
33
- const { user, profile } = useAuthStore();
34
  const { showToast } = useToastStore();
35
 
36
  // Data State
@@ -47,27 +46,6 @@ const AdminTickets = () => {
47
  const [teamFilter, setTeamFilter] = useState('All');
48
  const [agents, setAgents] = useState([]); // All staff/admins in the company
49
 
50
- const ticketMatchesFilters = useCallback((ticket) => {
51
- if (statusFilter !== 'All' && String(ticket.status || '').toLowerCase() !== statusFilter.toLowerCase()) return false;
52
- if (categoryFilter !== 'All' && ticket.category !== categoryFilter) return false;
53
- if (priorityFilter !== 'All' && String(ticket.priority || '').toLowerCase() !== priorityFilter.toLowerCase()) return false;
54
- if (teamFilter !== 'All' && ticket.assigned_team !== teamFilter) return false;
55
- return true;
56
- }, [categoryFilter, priorityFilter, statusFilter, teamFilter]);
57
-
58
- const handleRealtimeInsert = useCallback((ticket) => {
59
- showToast(`New Incident Reported: #${formatTicketId(ticket.id)}`, "success");
60
- }, [showToast]);
61
-
62
- const { lastChangedTicketId } = useTicketsRealtime({
63
- company: profile?.company,
64
- enabled: Boolean(profile),
65
- onTicketsChange: setTickets,
66
- onInsert: handleRealtimeInsert,
67
- shouldInclude: ticketMatchesFilters,
68
- channelName: 'admin_tickets_realtime',
69
- });
70
-
71
  const fetchInitialData = async () => {
72
  setLoading(true);
73
  try {
@@ -135,6 +113,38 @@ const AdminTickets = () => {
135
 
136
  useEffect(() => {
137
  fetchInitialData();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }, [statusFilter, categoryFilter, priorityFilter, teamFilter]);
139
 
140
  // Seed search from URL
@@ -295,11 +305,8 @@ const AdminTickets = () => {
295
  </tr>
296
  </thead>
297
  <tbody className="divide-y divide-slate-50">
298
- {filteredTickets.map((ticket) => {
299
- const wasLiveChanged = String(lastChangedTicketId) === String(ticket.id);
300
-
301
- return (
302
- <tr key={ticket.id} className={`hover:bg-slate-50/50 transition-colors group ${wasLiveChanged ? 'bg-emerald-50/70 ring-1 ring-emerald-100' : ''} ${isUpdating === ticket.id ? 'opacity-50 pointer-events-none' : ''}`}>
303
  {/* Ticket ID */}
304
  <td className="px-6 py-6">
305
  <span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
@@ -340,11 +347,6 @@ const AdminTickets = () => {
340
  {ticket.category}
341
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
342
  </span>
343
- {ticket?.metadata?.translation?.translated && (
344
- <span className="text-[10px] text-sky-700 mt-1">
345
- Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
346
- </span>
347
- )}
348
  </div>
349
  </td>
350
 
@@ -454,8 +456,7 @@ const AdminTickets = () => {
454
  </div>
455
  </td>
456
  </tr>
457
- );
458
- })}
459
  </tbody>
460
  </table>
461
  </div>
 
1
+ import React, { useState, useMemo, useEffect } from 'react';
2
  import { useNavigate, useLocation } from 'react-router-dom';
3
  import useAuthStore from "../../store/authStore";
4
  import useToastStore from "../../store/toastStore";
5
  import { supabase } from "../../lib/supabaseClient";
 
6
  import {
7
  Search,
8
  Filter,
 
29
  const AdminTickets = () => {
30
  const navigate = useNavigate();
31
  const location = useLocation();
32
+ const { user } = useAuthStore();
33
  const { showToast } = useToastStore();
34
 
35
  // Data State
 
46
  const [teamFilter, setTeamFilter] = useState('All');
47
  const [agents, setAgents] = useState([]); // All staff/admins in the company
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  const fetchInitialData = async () => {
50
  setLoading(true);
51
  try {
 
113
 
114
  useEffect(() => {
115
  fetchInitialData();
116
+
117
+ // 4. Real-time subscription to ticket changes
118
+ const { profile } = useAuthStore.getState();
119
+ const channel = supabase
120
+ .channel('admin_tickets_realtime')
121
+ .on(
122
+ 'postgres_changes',
123
+ {
124
+ event: '*',
125
+ schema: 'public',
126
+ table: 'tickets',
127
+ filter: profile?.company ? `company=eq.${profile.company}` : undefined
128
+ },
129
+ (payload) => {
130
+ console.log("Admin tickets sync event:", payload.eventType, payload.new);
131
+ if (payload.eventType === 'INSERT') {
132
+ setTickets(prev => [payload.new, ...prev]);
133
+ showToast(`New Incident Reported: #${payload.new.id}`, "success");
134
+ // Play a subtle sound or visual cue if needed
135
+ } else if (payload.eventType === 'UPDATE') {
136
+ setTickets(prev => prev.map(t => t.id === payload.new.id ? { ...t, ...payload.new } : t));
137
+ } else if (payload.eventType === 'DELETE') {
138
+ setTickets(prev => prev.filter(t => t.id === payload.old.id));
139
+ }
140
+ }
141
+ )
142
+ .subscribe();
143
+
144
+ return () => {
145
+ supabase.removeChannel(channel);
146
+ };
147
+
148
  }, [statusFilter, categoryFilter, priorityFilter, teamFilter]);
149
 
150
  // Seed search from URL
 
305
  </tr>
306
  </thead>
307
  <tbody className="divide-y divide-slate-50">
308
+ {filteredTickets.map((ticket) => (
309
+ <tr key={ticket.id} className={`hover:bg-slate-50/50 transition-colors group ${isUpdating === ticket.id ? 'opacity-50 pointer-events-none' : ''}`}>
 
 
 
310
  {/* Ticket ID */}
311
  <td className="px-6 py-6">
312
  <span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
 
347
  {ticket.category}
348
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
349
  </span>
 
 
 
 
 
350
  </div>
351
  </td>
352
 
 
456
  </div>
457
  </td>
458
  </tr>
459
+ ))}
 
460
  </tbody>
461
  </table>
462
  </div>
Frontend/src/components/shared/BugReportWidget.jsx CHANGED
@@ -23,6 +23,7 @@ function useDiagnostics() {
23
  const browserInfo = navigator.userAgent;
24
  const screenInfo = `${window.innerWidth}x${window.innerHeight}`;
25
 
 
26
  setDiagnostics(prev => ({
27
  ...prev,
28
  url: window.location.href,
@@ -30,6 +31,16 @@ function useDiagnostics() {
30
  screen: screenInfo
31
  }));
32
 
 
 
 
 
 
 
 
 
 
 
33
  // Global Error Listener
34
  const handleError = (e) => {
35
  setDiagnostics(prev => ({
@@ -40,6 +51,7 @@ function useDiagnostics() {
40
  window.addEventListener('error', handleError);
41
 
42
  return () => {
 
43
  window.removeEventListener('error', handleError);
44
  };
45
  }, []);
 
23
  const browserInfo = navigator.userAgent;
24
  const screenInfo = `${window.innerWidth}x${window.innerHeight}`;
25
 
26
+
27
  setDiagnostics(prev => ({
28
  ...prev,
29
  url: window.location.href,
 
31
  screen: screenInfo
32
  }));
33
 
34
+ // Intercept console.error
35
+ const originalConsoleError = console.error;
36
+ console.error = function (...args) {
37
+ setDiagnostics(prev => ({
38
+ ...prev,
39
+ consoleErrors: [...prev.consoleErrors, args.join(' ')].slice(-10) // keep last 10
40
+ }));
41
+ originalConsoleError.apply(console, args);
42
+ };
43
+
44
  // Global Error Listener
45
  const handleError = (e) => {
46
  setDiagnostics(prev => ({
 
51
  window.addEventListener('error', handleError);
52
 
53
  return () => {
54
+ console.error = originalConsoleError;
55
  window.removeEventListener('error', handleError);
56
  };
57
  }, []);
Frontend/src/config.js CHANGED
@@ -13,6 +13,5 @@ const getBackendUrl = () => {
13
  export const API_CONFIG = {
14
  BACKEND_URL: getBackendUrl(),
15
  FRONTEND_URL: window.location.origin,
16
- IS_PROD: import.meta.env.PROD,
17
- USE_MOCK: import.meta.env.VITE_USE_MOCK !== 'false' // default true
18
  };
 
13
  export const API_CONFIG = {
14
  BACKEND_URL: getBackendUrl(),
15
  FRONTEND_URL: window.location.origin,
16
+ IS_PROD: import.meta.env.PROD
 
17
  };
Frontend/src/pages/AdminSignup.jsx CHANGED
@@ -10,7 +10,6 @@ import {
10
  } from "lucide-react";
11
  import useAuthStore from "../store/authStore";
12
  import { Select } from "../components/ui/select";
13
- import { getPasswordValidation, getPasswordValidationMessage } from "../utils/passwordValidation";
14
 
15
  /**
16
  * AdminSignup — Premium Multi-step Company Registration
@@ -45,14 +44,6 @@ function AdminSignup() {
45
 
46
  const navigate = useNavigate();
47
  const { signup, loading, user, profile } = useAuthStore();
48
- const passwordRules = {
49
- minLength: 8,
50
- requireUppercase: true,
51
- requireNumber: true,
52
- requireSpecial: true,
53
- };
54
- const passwordChecks = getPasswordValidation(formData.password, passwordRules);
55
- const passwordWarning = getPasswordValidationMessage(passwordChecks, passwordRules);
56
 
57
  // Redirect if already logged in and verified
58
  useEffect(() => {
@@ -89,8 +80,8 @@ function AdminSignup() {
89
  setError("Please fill in all required personal information.");
90
  return;
91
  }
92
- if (passwordWarning) {
93
- setError(passwordWarning);
94
  return;
95
  }
96
  if (formData.password !== formData.confirmPassword) {
@@ -431,7 +422,7 @@ function AdminSignup() {
431
  </div>
432
  {/* Strength Meter */}
433
  {formData.password && (
434
- <div className="mt-2 space-y-2">
435
  <div className="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-gray-400">
436
  <span>Strength: {getStrengthText()}</span>
437
  <span>{passwordStrength}%</span>
@@ -443,12 +434,6 @@ function AdminSignup() {
443
  animate={{ width: `${passwordStrength}%` }}
444
  />
445
  </div>
446
- <div
447
- aria-live="polite"
448
- className={`text-[11px] font-semibold ${passwordWarning ? "text-red-600" : "text-emerald-700"}`}
449
- >
450
- {passwordWarning || "Password requirements met."}
451
- </div>
452
  </div>
453
  )}
454
  </div>
 
10
  } from "lucide-react";
11
  import useAuthStore from "../store/authStore";
12
  import { Select } from "../components/ui/select";
 
13
 
14
  /**
15
  * AdminSignup — Premium Multi-step Company Registration
 
44
 
45
  const navigate = useNavigate();
46
  const { signup, loading, user, profile } = useAuthStore();
 
 
 
 
 
 
 
 
47
 
48
  // Redirect if already logged in and verified
49
  useEffect(() => {
 
80
  setError("Please fill in all required personal information.");
81
  return;
82
  }
83
+ if (formData.password.length < 8) {
84
+ setError("Password must be at least 8 characters long.");
85
  return;
86
  }
87
  if (formData.password !== formData.confirmPassword) {
 
422
  </div>
423
  {/* Strength Meter */}
424
  {formData.password && (
425
+ <div className="mt-2 space-y-1">
426
  <div className="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-gray-400">
427
  <span>Strength: {getStrengthText()}</span>
428
  <span>{passwordStrength}%</span>
 
434
  animate={{ width: `${passwordStrength}%` }}
435
  />
436
  </div>
 
 
 
 
 
 
437
  </div>
438
  )}
439
  </div>
Frontend/src/pages/LandingPage.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useRef, useEffect, useState, useMemo } from 'react';
2
 
3
  import { motion, AnimatePresence } from 'framer-motion';
4
  import { useNavigate } from 'react-router-dom';
@@ -14,12 +14,6 @@ import {
14
  import useAuthStore from '../store/authStore';
15
  import TeamSection from '../components/landing/TeamSection';
16
 
17
- const CTA_TRANSITION = 'transition-all duration-200 ease-out transform-gpu will-change-transform';
18
- const CTA_SCALE = 'hover:scale-105 active:scale-[0.98]';
19
- const CTA_PRIMARY_GLOW = 'shadow-lg shadow-emerald-900/20 hover:shadow-emerald-900/35';
20
- const CTA_ICON_SHIFT = 'transition-transform duration-200 group-hover:translate-x-1';
21
- const CTA_PLAY_SHIFT = 'transition-transform duration-200 group-hover:translate-x-0.5';
22
-
23
  // ---- Count-up animation component ----
24
  function AnimatedStat({ target, suffix = '', prefix = '', label, isWord = false }) {
25
  const [display, setDisplay] = useState(isWord ? target : '0');
@@ -130,9 +124,9 @@ function DemoModal({ onClose }) {
130
  <div className="flex gap-3 w-full md:w-auto">
131
  <button
132
  onClick={() => { onClose(); window.location.href = '/admin-signup'; }}
133
- className={`group flex-1 md:px-8 bg-emerald-600 hover:bg-emerald-500 text-white py-3 rounded-xl font-black italic uppercase tracking-wider ${CTA_TRANSITION} ${CTA_SCALE} ${CTA_PRIMARY_GLOW} flex items-center justify-center gap-2`}
134
  >
135
- Start Free <ArrowRight className={`w-4 h-4 ml-1 ${CTA_ICON_SHIFT}`} />
136
  </button>
137
  </div>
138
  </div>
@@ -151,7 +145,7 @@ export default function LandingPage() {
151
  const [activeStep, setActiveStep] = useState(0);
152
  const [isRedirecting, setIsRedirecting] = useState(false);
153
 
154
- const steps = useMemo(() => [
155
  {
156
  num: '01',
157
  title: 'Messy User Input',
@@ -214,7 +208,7 @@ export default function LandingPage() {
214
  </div>
215
  )
216
  }
217
- ], []);
218
 
219
  useEffect(() => {
220
  if (!loading && user && profile) {
@@ -231,7 +225,7 @@ export default function LandingPage() {
231
  period: '/mo',
232
  desc: 'Perfect for small teams exploring AI helpdesk.',
233
  cta: 'Get Started Free',
234
- ctaStyle: `border border-gray-200 text-gray-700 hover:border-emerald-900 hover:text-emerald-800 group ${CTA_TRANSITION} ${CTA_SCALE}`,
235
  features: ['Up to 50 tickets/mo', 'Basic AI Categorization', 'Email Support', '1 Team Member', 'Public API Access'],
236
  popular: false,
237
  },
@@ -241,7 +235,7 @@ export default function LandingPage() {
241
  period: '/mo',
242
  desc: 'For growing IT teams needing full automation.',
243
  cta: 'Start Free Trial',
244
- ctaStyle: `bg-emerald-900 text-white hover:bg-emerald-800 group ${CTA_TRANSITION} ${CTA_SCALE} ${CTA_PRIMARY_GLOW}`,
245
  features: ['Up to 500 tickets/mo', 'Advanced AI Parsing', 'Priority Detection Engine', 'Duplicate Detection', '5 Team Members', 'Priority Email Support'],
246
  popular: true,
247
  },
@@ -251,7 +245,7 @@ export default function LandingPage() {
251
  period: '',
252
  desc: 'For large organizations with complex IT landscapes.',
253
  cta: 'Contact Sales',
254
- ctaStyle: `border border-gray-200 text-gray-700 hover:border-emerald-900 hover:text-emerald-800 group ${CTA_TRANSITION} ${CTA_SCALE}`,
255
  features: ['Unlimited tickets', 'Custom AI Fine-Tuning', 'SSO & Audit Logs', 'Dedicated SLA Manager', 'Unlimited Members', 'VAPT & Compliance Reports'],
256
  popular: false,
257
  },
@@ -322,13 +316,13 @@ export default function LandingPage() {
322
  </button>
323
  <button
324
  onClick={() => setShowDemo(true)}
325
- className={`group text-sm font-semibold text-emerald-800 border border-emerald-200 px-4 py-2 rounded-lg hover:bg-emerald-50 ${CTA_TRANSITION} ${CTA_SCALE} flex items-center gap-1.5`}
326
  >
327
- <Play className={`w-3.5 h-3.5 fill-emerald-700 ${CTA_PLAY_SHIFT}`} /> Watch Demo
328
  </button>
329
  <button
330
  onClick={() => navigate('/admin-signup')}
331
- className={`group bg-emerald-900 hover:bg-emerald-800 text-white px-5 py-2.5 rounded-lg text-sm font-semibold ${CTA_TRANSITION} ${CTA_SCALE} ${CTA_PRIMARY_GLOW}`}
332
  >
333
  Get Started Free
334
  </button>
@@ -351,13 +345,13 @@ export default function LandingPage() {
351
  <a href="#how-it-works" onClick={() => setIsMenuOpen(false)} className="block text-base font-semibold text-gray-700 hover:text-emerald-800 py-2">How It Works</a>
352
  <a href="#pricing" onClick={() => setIsMenuOpen(false)} className="block text-base font-semibold text-gray-700 hover:text-emerald-800 py-2">Pricing</a>
353
  <div className="pt-4 flex flex-col gap-3 border-t border-gray-100">
354
- <button onClick={() => { setIsMenuOpen(false); setShowDemo(true); }} className={`group w-full text-center py-2.5 text-emerald-800 font-semibold border border-emerald-200 rounded-lg flex items-center justify-center gap-2 ${CTA_TRANSITION} ${CTA_SCALE}`}>
355
- <Play className={`w-4 h-4 fill-emerald-700 ${CTA_PLAY_SHIFT}`} /> Watch Demo
356
  </button>
357
  <button onClick={() => navigate('/login')} className="w-full text-center py-2.5 text-gray-700 font-semibold border border-gray-100 rounded-lg">
358
  Sign In
359
  </button>
360
- <button onClick={() => navigate('/admin-signup')} className={`group w-full bg-emerald-900 text-white py-3 rounded-lg font-semibold ${CTA_TRANSITION} ${CTA_SCALE} ${CTA_PRIMARY_GLOW}`}>
361
  Get Started Free
362
  </button>
363
  </div>
@@ -388,15 +382,15 @@ export default function LandingPage() {
388
  <div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6">
389
  <button
390
  onClick={() => navigate('/admin-signup')}
391
- className={`group w-full sm:w-auto px-8 py-4 bg-emerald-900 text-white rounded-xl font-bold ${CTA_PRIMARY_GLOW} hover:bg-emerald-800 ${CTA_TRANSITION} ${CTA_SCALE} flex items-center justify-center gap-2 text-base`}
392
  >
393
- Get Started Free <ArrowRight className={`w-5 h-5 ${CTA_ICON_SHIFT}`} />
394
  </button>
395
  <button
396
  onClick={() => setShowDemo(true)}
397
- className={`group w-full sm:w-auto px-8 py-4 bg-white text-gray-700 border border-gray-200 rounded-xl font-semibold hover:border-emerald-500 hover:text-emerald-700 ${CTA_TRANSITION} ${CTA_SCALE} flex items-center justify-center gap-2 text-base`}
398
  >
399
- <Play className={`w-4 h-4 fill-gray-500 ${CTA_PLAY_SHIFT}`} /> Watch a Demo
400
  </button>
401
  </div>
402
 
@@ -433,7 +427,7 @@ export default function LandingPage() {
433
  <div className="mb-4">
434
  <h3 className="text-sm font-bold text-gray-800 mb-1">Subject: Wifi down again in Lab 3??</h3>
435
  <p className="text-sm text-gray-600 leading-relaxed">
436
- Hey support, the wifi in <span className="bg-yellow-200 dark:bg-yellow-500/30 dark:text-yellow-200 text-yellow-900 px-1 rounded font-medium">downstairs lab 3</span> is acting up again.
437
  Can't connect at all. Class starts in 20 mins, need this fixed ASAP!<br /><br />
438
  Thanks,<br />Sarah
439
  </p>
@@ -664,7 +658,7 @@ export default function LandingPage() {
664
  <div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 text-emerald-400 rounded-full text-[10px] md:text-xs font-bold uppercase tracking-widest border border-emerald-500/20 mb-4 md:mb-6">
665
  The Journey
666
  </div>
667
- <h2 className="text-3xl sm:text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[0.95] mb-8 md:mb-12">
668
  From Chaos <br />
669
  to <span className="text-emerald-500">Clarity.</span>
670
  </h2>
@@ -675,25 +669,28 @@ export default function LandingPage() {
675
  key={idx}
676
  onMouseEnter={() => setActiveStep(idx)}
677
  onClick={() => setActiveStep(idx)}
678
- className={`group cursor-pointer p-6 rounded-3xl transition-all duration-300 border ${activeStep === idx
679
  ? 'bg-white/10 border-white/20 shadow-2xl shadow-black/20'
680
  : 'bg-transparent border-transparent hover:bg-white/5 opacity-40 hover:opacity-100'
681
  }`}
682
  >
683
  <div className="flex items-start gap-4 md:gap-6">
684
- <div className={`shrink-0 w-10 md:w-12 h-10 md:h-12 rounded-xl md:rounded-2xl flex items-center justify-center font-bold text-lg md:text-xl transition-all duration-300 ${activeStep === idx ? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20 scale-105' : 'bg-white/10 text-white/40'}`}>
685
  {step.num}
686
  </div>
687
  <div>
688
- <h3 className={`text-xl font-semibold tracking-tight transition-colors duration-300 ${activeStep === idx ? 'text-white' : 'text-white/60'}`}>
689
  {step.title}
690
  </h3>
691
- <p
692
- aria-hidden={activeStep !== idx}
693
- className={`text-white/60 text-sm mt-3 leading-relaxed max-w-sm overflow-hidden transition-all duration-300 ${activeStep === idx ? 'opacity-100 max-h-24' : 'opacity-0 max-h-0'}`}
694
- >
695
- {step.desc}
696
- </p>
 
 
 
697
  </div>
698
  </div>
699
  </div>
@@ -704,14 +701,14 @@ export default function LandingPage() {
704
  {/* Right: Visual Display */}
705
  <div className="w-full md:w-1/2 h-[350px] md:h-[500px] relative">
706
  <div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-[32px] md:rounded-[40px] border border-white/5 backdrop-blur-3xl overflow-hidden p-6 md:p-12 flex items-center justify-center">
707
- <AnimatePresence mode="sync">
708
  <motion.div
709
  key={activeStep}
710
- initial={{ opacity: 0, scale: 0.98, y: 10 }}
711
  animate={{ opacity: 1, scale: 1, y: 0 }}
712
- exit={{ opacity: 0, scale: 1.01, y: -8 }}
713
- transition={{ duration: 0.28, ease: 'easeOut' }}
714
- className="w-full h-full flex flex-col items-center justify-center will-change-transform"
715
  >
716
  <div className="mb-6 inline-flex items-center gap-2 px-4 py-1.5 bg-white/5 text-white/50 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
717
  <div className={`w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse`} />
@@ -775,7 +772,7 @@ export default function LandingPage() {
775
  <button
776
  onClick={() => handlePricingClick(name)}
777
  disabled={isRedirecting && name === 'Growth'}
778
- className={`w-full flex items-center justify-center gap-2 py-3 rounded-xl font-semibold mb-8 text-sm ${ctaStyle} ${isRedirecting && name === 'Growth' ? 'opacity-80 cursor-not-allowed' : ''}`}
779
  >
780
  {isRedirecting && name === 'Growth' ? (
781
  <>
@@ -818,15 +815,15 @@ export default function LandingPage() {
818
  <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
819
  <button
820
  onClick={() => navigate('/admin-signup')}
821
- className={`group w-full sm:w-auto px-8 py-4 bg-white text-emerald-900 font-bold rounded-xl hover:bg-green-50 ${CTA_TRANSITION} ${CTA_SCALE} shadow-xl`}
822
  >
823
  Get Started Free
824
  </button>
825
  <button
826
  onClick={() => setShowDemo(true)}
827
- className={`group w-full sm:w-auto px-8 py-4 border border-white/30 text-white font-semibold rounded-xl hover:bg-white/10 ${CTA_TRANSITION} ${CTA_SCALE} flex items-center justify-center gap-2`}
828
  >
829
- <Play className={`w-4 h-4 fill-white ${CTA_PLAY_SHIFT}`} /> Watch Demo
830
  </button>
831
  </div>
832
  <div className="mt-8">
 
1
+ import React, { useRef, useEffect, useState } from 'react';
2
 
3
  import { motion, AnimatePresence } from 'framer-motion';
4
  import { useNavigate } from 'react-router-dom';
 
14
  import useAuthStore from '../store/authStore';
15
  import TeamSection from '../components/landing/TeamSection';
16
 
 
 
 
 
 
 
17
  // ---- Count-up animation component ----
18
  function AnimatedStat({ target, suffix = '', prefix = '', label, isWord = false }) {
19
  const [display, setDisplay] = useState(isWord ? target : '0');
 
124
  <div className="flex gap-3 w-full md:w-auto">
125
  <button
126
  onClick={() => { onClose(); window.location.href = '/admin-signup'; }}
127
+ className="flex-1 md:px-8 bg-emerald-600 hover:bg-emerald-500 text-white py-3 rounded-xl font-black italic uppercase tracking-wider transition-all flex items-center justify-center gap-2 shadow-lg shadow-emerald-500/20"
128
  >
129
+ Start Free <ArrowRight className="w-4 h-4 ml-1" />
130
  </button>
131
  </div>
132
  </div>
 
145
  const [activeStep, setActiveStep] = useState(0);
146
  const [isRedirecting, setIsRedirecting] = useState(false);
147
 
148
+ const steps = [
149
  {
150
  num: '01',
151
  title: 'Messy User Input',
 
208
  </div>
209
  )
210
  }
211
+ ];
212
 
213
  useEffect(() => {
214
  if (!loading && user && profile) {
 
225
  period: '/mo',
226
  desc: 'Perfect for small teams exploring AI helpdesk.',
227
  cta: 'Get Started Free',
228
+ ctaStyle: 'border border-gray-200 text-gray-700 hover:border-emerald-900 hover:text-emerald-800',
229
  features: ['Up to 50 tickets/mo', 'Basic AI Categorization', 'Email Support', '1 Team Member', 'Public API Access'],
230
  popular: false,
231
  },
 
235
  period: '/mo',
236
  desc: 'For growing IT teams needing full automation.',
237
  cta: 'Start Free Trial',
238
+ ctaStyle: 'bg-emerald-900 text-white hover:bg-emerald-800 shadow-lg shadow-emerald-900/20',
239
  features: ['Up to 500 tickets/mo', 'Advanced AI Parsing', 'Priority Detection Engine', 'Duplicate Detection', '5 Team Members', 'Priority Email Support'],
240
  popular: true,
241
  },
 
245
  period: '',
246
  desc: 'For large organizations with complex IT landscapes.',
247
  cta: 'Contact Sales',
248
+ ctaStyle: 'border border-gray-200 text-gray-700 hover:border-emerald-900 hover:text-emerald-800',
249
  features: ['Unlimited tickets', 'Custom AI Fine-Tuning', 'SSO & Audit Logs', 'Dedicated SLA Manager', 'Unlimited Members', 'VAPT & Compliance Reports'],
250
  popular: false,
251
  },
 
316
  </button>
317
  <button
318
  onClick={() => setShowDemo(true)}
319
+ className="text-sm font-semibold text-emerald-800 border border-emerald-200 px-4 py-2 rounded-lg hover:bg-emerald-50 transition-all flex items-center gap-1.5"
320
  >
321
+ <Play className="w-3.5 h-3.5 fill-emerald-700" /> Watch Demo
322
  </button>
323
  <button
324
  onClick={() => navigate('/admin-signup')}
325
+ className="bg-emerald-900 hover:bg-emerald-800 text-white px-5 py-2.5 rounded-lg text-sm font-semibold transition-all shadow-lg shadow-emerald-900/20"
326
  >
327
  Get Started Free
328
  </button>
 
345
  <a href="#how-it-works" onClick={() => setIsMenuOpen(false)} className="block text-base font-semibold text-gray-700 hover:text-emerald-800 py-2">How It Works</a>
346
  <a href="#pricing" onClick={() => setIsMenuOpen(false)} className="block text-base font-semibold text-gray-700 hover:text-emerald-800 py-2">Pricing</a>
347
  <div className="pt-4 flex flex-col gap-3 border-t border-gray-100">
348
+ <button onClick={() => { setIsMenuOpen(false); setShowDemo(true); }} className="w-full text-center py-2.5 text-emerald-800 font-semibold border border-emerald-200 rounded-lg flex items-center justify-center gap-2">
349
+ <Play className="w-4 h-4 fill-emerald-700" /> Watch Demo
350
  </button>
351
  <button onClick={() => navigate('/login')} className="w-full text-center py-2.5 text-gray-700 font-semibold border border-gray-100 rounded-lg">
352
  Sign In
353
  </button>
354
+ <button onClick={() => navigate('/admin-signup')} className="w-full bg-emerald-900 text-white py-3 rounded-lg font-semibold shadow">
355
  Get Started Free
356
  </button>
357
  </div>
 
382
  <div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6">
383
  <button
384
  onClick={() => navigate('/admin-signup')}
385
+ className="w-full sm:w-auto px-8 py-4 bg-emerald-900 text-white rounded-xl font-bold shadow-xl shadow-emerald-900/25 hover:bg-emerald-800 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 text-base"
386
  >
387
+ Get Started Free <ArrowRight className="w-5 h-5" />
388
  </button>
389
  <button
390
  onClick={() => setShowDemo(true)}
391
+ className="w-full sm:w-auto px-8 py-4 bg-white text-gray-700 border border-gray-200 rounded-xl font-semibold hover:border-emerald-500 hover:text-emerald-700 transition-all flex items-center justify-center gap-2 text-base"
392
  >
393
+ <Play className="w-4 h-4 fill-gray-500" /> Watch a Demo
394
  </button>
395
  </div>
396
 
 
427
  <div className="mb-4">
428
  <h3 className="text-sm font-bold text-gray-800 mb-1">Subject: Wifi down again in Lab 3??</h3>
429
  <p className="text-sm text-gray-600 leading-relaxed">
430
+ Hey support, the wifi in <span className="bg-yellow-100 px-1 rounded">downstairs lab 3</span> is acting up again.
431
  Can't connect at all. Class starts in 20 mins, need this fixed ASAP!<br /><br />
432
  Thanks,<br />Sarah
433
  </p>
 
658
  <div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 text-emerald-400 rounded-full text-[10px] md:text-xs font-bold uppercase tracking-widest border border-emerald-500/20 mb-4 md:mb-6">
659
  The Journey
660
  </div>
661
+ <h2 className="text-3xl sm:text-4xl md:text-6xl font-black text-white tracking-tight leading-[0.9] mb-8 md:mb-12 italic uppercase">
662
  From Chaos <br />
663
  to <span className="text-emerald-500">Clarity.</span>
664
  </h2>
 
669
  key={idx}
670
  onMouseEnter={() => setActiveStep(idx)}
671
  onClick={() => setActiveStep(idx)}
672
+ className={`group cursor-pointer p-6 rounded-3xl transition-all duration-500 border ${activeStep === idx
673
  ? 'bg-white/10 border-white/20 shadow-2xl shadow-black/20'
674
  : 'bg-transparent border-transparent hover:bg-white/5 opacity-40 hover:opacity-100'
675
  }`}
676
  >
677
  <div className="flex items-start gap-4 md:gap-6">
678
+ <div className={`shrink-0 w-10 md:w-12 h-10 md:h-12 rounded-xl md:rounded-2xl flex items-center justify-center font-black text-lg md:text-xl italic transition-all duration-500 ${activeStep === idx ? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20 rotate-12 scale-110' : 'bg-white/10 text-white/40'}`}>
679
  {step.num}
680
  </div>
681
  <div>
682
+ <h3 className={`text-xl font-black italic uppercase transition-colors duration-500 ${activeStep === idx ? 'text-white' : 'text-white/60'}`}>
683
  {step.title}
684
  </h3>
685
+ {activeStep === idx && (
686
+ <motion.p
687
+ initial={{ opacity: 0, height: 0 }}
688
+ animate={{ opacity: 1, height: 'auto' }}
689
+ className="text-white/60 text-sm mt-3 leading-relaxed max-w-sm"
690
+ >
691
+ {step.desc}
692
+ </motion.p>
693
+ )}
694
  </div>
695
  </div>
696
  </div>
 
701
  {/* Right: Visual Display */}
702
  <div className="w-full md:w-1/2 h-[350px] md:h-[500px] relative">
703
  <div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-[32px] md:rounded-[40px] border border-white/5 backdrop-blur-3xl overflow-hidden p-6 md:p-12 flex items-center justify-center">
704
+ <AnimatePresence mode="wait">
705
  <motion.div
706
  key={activeStep}
707
+ initial={{ opacity: 0, scale: 0.9, y: 20 }}
708
  animate={{ opacity: 1, scale: 1, y: 0 }}
709
+ exit={{ opacity: 0, scale: 1.1, y: -20 }}
710
+ transition={{ type: 'spring', damping: 20, stiffness: 100 }}
711
+ className="w-full h-full flex flex-col items-center justify-center"
712
  >
713
  <div className="mb-6 inline-flex items-center gap-2 px-4 py-1.5 bg-white/5 text-white/50 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
714
  <div className={`w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse`} />
 
772
  <button
773
  onClick={() => handlePricingClick(name)}
774
  disabled={isRedirecting && name === 'Growth'}
775
+ className={`w-full flex items-center justify-center gap-2 py-3 rounded-xl font-semibold transition-all mb-8 text-sm ${ctaStyle} ${isRedirecting && name === 'Growth' ? 'opacity-80 cursor-not-allowed' : ''}`}
776
  >
777
  {isRedirecting && name === 'Growth' ? (
778
  <>
 
815
  <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
816
  <button
817
  onClick={() => navigate('/admin-signup')}
818
+ className="w-full sm:w-auto px-8 py-4 bg-white text-emerald-900 font-bold rounded-xl hover:bg-green-50 transition-all shadow-xl"
819
  >
820
  Get Started Free
821
  </button>
822
  <button
823
  onClick={() => setShowDemo(true)}
824
+ className="w-full sm:w-auto px-8 py-4 border border-white/30 text-white font-semibold rounded-xl hover:bg-white/10 transition-all flex items-center justify-center gap-2"
825
  >
826
+ <Play className="w-4 h-4 fill-white" /> Watch Demo
827
  </button>
828
  </div>
829
  <div className="mt-8">
Frontend/src/pages/Signup.jsx CHANGED
@@ -3,7 +3,6 @@ import { useNavigate, Link } from "react-router-dom";
3
  import useAuthStore from "../store/authStore";
4
  import { supabase } from "../lib/supabaseClient";
5
  import { Eye, EyeOff, BrainCircuit, ArrowRight, Loader2, CheckCircle2, ChevronDown, Search, Building2, ArrowLeft } from "lucide-react";
6
- import { getPasswordValidation, getPasswordValidationMessage } from "../utils/passwordValidation";
7
 
8
  function Signup() {
9
  const [email, setEmail] = useState("");
@@ -28,10 +27,6 @@ function Signup() {
28
  const dropdownRef = useRef(null);
29
  const navigate = useNavigate();
30
  const { signup, user, profile } = useAuthStore();
31
- const passwordRules = { minLength: 6 };
32
- const passwordChecks = getPasswordValidation(password, passwordRules);
33
- const passwordWarning = getPasswordValidationMessage(passwordChecks, passwordRules);
34
- const confirmPasswordWarning = confirmPassword && password !== confirmPassword ? "Passwords do not match." : "";
35
 
36
  // Fetch and subscribe to companies
37
  useEffect(() => {
@@ -118,13 +113,13 @@ function Signup() {
118
  return;
119
  }
120
 
121
- if (passwordWarning) {
122
- setError(passwordWarning);
123
  return;
124
  }
125
 
126
- if (confirmPasswordWarning) {
127
- setError(confirmPasswordWarning);
128
  return;
129
  }
130
 
@@ -326,11 +321,6 @@ function Signup() {
326
  {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
327
  </button>
328
  </div>
329
- {password && (
330
- <p aria-live="polite" className={`mt-2 text-[11px] font-semibold ${passwordWarning ? "text-red-600" : "text-emerald-700"}`}>
331
- {passwordWarning || "Password looks good."}
332
- </p>
333
- )}
334
  </div>
335
  <div className="relative">
336
  <label className="block mb-2" style={labelStyle}>Confirm</label>
@@ -341,11 +331,6 @@ function Signup() {
341
  {showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
342
  </button>
343
  </div>
344
- {confirmPasswordWarning && (
345
- <p aria-live="polite" className="mt-2 text-[11px] font-semibold text-red-600">
346
- {confirmPasswordWarning}
347
- </p>
348
- )}
349
  </div>
350
  </div>
351
 
 
3
  import useAuthStore from "../store/authStore";
4
  import { supabase } from "../lib/supabaseClient";
5
  import { Eye, EyeOff, BrainCircuit, ArrowRight, Loader2, CheckCircle2, ChevronDown, Search, Building2, ArrowLeft } from "lucide-react";
 
6
 
7
  function Signup() {
8
  const [email, setEmail] = useState("");
 
27
  const dropdownRef = useRef(null);
28
  const navigate = useNavigate();
29
  const { signup, user, profile } = useAuthStore();
 
 
 
 
30
 
31
  // Fetch and subscribe to companies
32
  useEffect(() => {
 
113
  return;
114
  }
115
 
116
+ if (password.length < 6) {
117
+ setError("Password must be at least 6 characters long.");
118
  return;
119
  }
120
 
121
+ if (password !== confirmPassword) {
122
+ setError("Passwords do not match.");
123
  return;
124
  }
125
 
 
321
  {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
322
  </button>
323
  </div>
 
 
 
 
 
324
  </div>
325
  <div className="relative">
326
  <label className="block mb-2" style={labelStyle}>Confirm</label>
 
331
  {showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
332
  </button>
333
  </div>
 
 
 
 
 
334
  </div>
335
  </div>
336
 
Frontend/src/services/aiAssistant.js CHANGED
@@ -1,4 +1,5 @@
1
- import { supabase } from "../lib/supabaseClient";
 
2
 
3
  // ============================================================
4
  // MULTI-API FAILOVER CONFIGURATION
@@ -7,30 +8,45 @@ import { supabase } from "../lib/supabaseClient";
7
  // ============================================================
8
 
9
  const buildConfigList = () => {
 
10
  const configs = [];
11
 
12
  // Priority 1: Native Gemini — try modern flash models
13
- configs.push(
14
- { provider: 'gemini', model: 'gemini-2.5-flash' },
15
- { provider: 'gemini', model: 'gemini-2.5-flash-lite' },
16
- { provider: 'gemini', model: 'gemini-2.0-flash' }
17
- );
 
 
 
 
 
18
 
19
  // Priority 2: OpenRouter — updated model slugs (verified working as of 2025)
 
 
 
 
20
  const openrouterModels = [
21
  'meta-llama/llama-3.2-3b-instruct:free',
22
  'microsoft/phi-3-mini-128k-instruct:free',
23
  'mistralai/mistral-7b-instruct:free',
24
  'google/gemma-2-9b-it:free',
25
  ];
26
- openrouterModels.forEach((model) => {
27
- configs.push({ provider: 'openrouter', model });
 
 
28
  });
29
 
30
  // Priority 3: Groq — use stable, currently-available models
 
 
 
31
  const groqModels = ['llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'];
32
- groqModels.forEach((model) => {
33
- configs.push({ provider: 'groq', model });
34
  });
35
 
36
  return configs;
@@ -41,8 +57,11 @@ const buildConfigList = () => {
41
  // PROVIDER HANDLERS
42
  // ============================================================
43
 
44
- const buildGeminiMessages = (promptText, history, image) => {
45
- const formattedHistory = history.map(msg => {
 
 
 
46
  const parts = [{ text: msg.text || "" }];
47
  if (msg.image) {
48
  const [mime, data] = msg.image.split(';base64,');
@@ -51,110 +70,72 @@ const buildGeminiMessages = (promptText, history, image) => {
51
  return { role: msg.role === 'bot' ? 'model' : 'user', parts };
52
  });
53
 
 
 
 
 
 
 
 
54
  const messageParts = [{ text: promptText }];
55
  if (image) {
56
  const [mime, data] = image.split(';base64,');
57
  messageParts.push({ inlineData: { mimeType: mime.split(':')[1] || 'image/png', data } });
58
  }
59
 
60
- return formattedHistory.length > 0
61
- ? [...formattedHistory, { role: 'user', parts: messageParts }]
62
- : [{ role: 'user', parts: messageParts }];
63
  };
64
 
65
- const buildOpenAICompatMessages = (promptText, history, image) => {
66
- const messages = history.map(msg => {
67
- const content = msg.image
68
- ? [
69
- { type: 'text', text: msg.text || '' },
70
- { type: 'image_url', image_url: { url: msg.image } }
71
- ]
72
- : msg.text || '';
73
-
74
- return {
75
- role: msg.role === 'bot' ? 'assistant' : 'user',
76
- content,
77
- };
78
- });
79
 
80
  const userContent = image
81
  ? [{ type: "text", text: promptText }, { type: "image_url", image_url: { url: image } }]
82
  : promptText;
83
 
84
  messages.push({ role: "user", content: userContent });
85
- return messages;
86
- };
87
-
88
- const extractResponseText = (data) => {
89
- if (typeof data === 'string') return data;
90
- if (!data || typeof data !== 'object') return '';
91
-
92
- const openAiContent = data.choices?.[0]?.message?.content;
93
- if (typeof openAiContent === 'string') return openAiContent;
94
- if (Array.isArray(openAiContent)) {
95
- return openAiContent
96
- .map(part => part?.text || part?.content || '')
97
- .filter(Boolean)
98
- .join('');
99
- }
100
-
101
- const geminiParts = data.candidates?.[0]?.content?.parts;
102
- if (Array.isArray(geminiParts)) {
103
- return geminiParts
104
- .map(part => part?.text || '')
105
- .filter(Boolean)
106
- .join('');
107
- }
108
 
109
- if (typeof data.candidates?.[0]?.content === 'string') {
110
- return data.candidates[0].content;
111
- }
112
-
113
- if (typeof data.text === 'string') return data.text;
114
-
115
- return '';
116
- };
117
-
118
- const callProxy = async (config, promptText, history, image) => {
119
- const body = config.provider === 'gemini'
120
- ? {
121
- provider: config.provider,
122
- model: config.model,
123
- messages: buildGeminiMessages(promptText, history, image),
124
- }
125
- : {
126
- provider: config.provider,
127
- model: config.model,
128
- messages: buildOpenAICompatMessages(promptText, history, config.provider === 'groq' ? null : image),
129
- };
130
-
131
- const { data, error } = await supabase.functions.invoke('ai-proxy', { body });
132
-
133
- if (error) {
134
- const invokeError = new Error(error.message || 'AI proxy request failed');
135
- invokeError.status = error.status || error?.context?.status;
136
- throw invokeError;
137
- }
138
 
139
- const responseText = extractResponseText(data);
140
- if (!responseText) {
141
- throw new Error(`No response received from ${config.provider}`);
 
142
  }
143
-
144
- return responseText;
145
  };
146
 
147
  // Core failover runner — shared by both exported functions
148
  const runWithFailover = async (promptText, history, image) => {
149
  const configList = buildConfigList();
150
- if (configList.length === 0) throw new Error("No AI providers configured");
151
 
152
  for (let i = 0; i < configList.length; i++) {
153
  const config = configList[i];
154
  console.log(`[AI Failover] Trying ${i + 1}/${configList.length}: ${config.provider} (${config.model})`);
155
 
156
  try {
157
- return await callProxy(config, promptText, history, image);
 
 
 
 
 
 
 
 
 
 
 
158
  } catch (error) {
159
  const isRateLimit = error.status === 429
160
  || error.message?.includes('429')
 
1
+ import { GoogleGenerativeAI } from "@google/generative-ai";
2
+ import { API_CONFIG } from "../config";
3
 
4
  // ============================================================
5
  // MULTI-API FAILOVER CONFIGURATION
 
8
  // ============================================================
9
 
10
  const buildConfigList = () => {
11
+ const env = import.meta.env;
12
  const configs = [];
13
 
14
  // Priority 1: Native Gemini — try modern flash models
15
+ const geminiKeys = [
16
+ env.VITE_GEMINI_API_KEY_1, env.VITE_GEMINI_API_KEY_2,
17
+ env.VITE_GEMINI_API_KEY_3, env.VITE_GEMINI_API_KEY_4
18
+ ].filter(Boolean);
19
+ // Try each key with gemini-2.5-flash first (most robust, active free tier), then gemini-2.5-flash-lite
20
+ geminiKeys.forEach(key => {
21
+ configs.push({ provider: 'gemini', key, model: 'gemini-2.5-flash' });
22
+ configs.push({ provider: 'gemini', key, model: 'gemini-2.5-flash-lite' });
23
+ configs.push({ provider: 'gemini', key, model: 'gemini-2.0-flash' });
24
+ });
25
 
26
  // Priority 2: OpenRouter — updated model slugs (verified working as of 2025)
27
+ const openrouterKeys = [
28
+ env.VITE_OPENROUTER_API_KEY_1, env.VITE_OPENROUTER_API_KEY_2,
29
+ env.VITE_OPENROUTER_API_KEY_3, env.VITE_OPENROUTER_API_KEY_4,
30
+ ].filter(Boolean);
31
  const openrouterModels = [
32
  'meta-llama/llama-3.2-3b-instruct:free',
33
  'microsoft/phi-3-mini-128k-instruct:free',
34
  'mistralai/mistral-7b-instruct:free',
35
  'google/gemma-2-9b-it:free',
36
  ];
37
+ openrouterKeys.forEach((key, idx) => {
38
+ // Each key tries two models for extra redundancy
39
+ configs.push({ provider: 'openrouter', key, model: openrouterModels[idx % openrouterModels.length] });
40
+ configs.push({ provider: 'openrouter', key, model: openrouterModels[(idx + 1) % openrouterModels.length] });
41
  });
42
 
43
  // Priority 3: Groq — use stable, currently-available models
44
+ const groqKeys = [
45
+ env.VITE_GROQ_API_KEY_1, env.VITE_GROQ_API_KEY_2, env.VITE_GROQ_API_KEY_3
46
+ ].filter(Boolean);
47
  const groqModels = ['llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'];
48
+ groqKeys.forEach((key, idx) => {
49
+ configs.push({ provider: 'groq', key, model: groqModels[idx % groqModels.length] });
50
  });
51
 
52
  return configs;
 
57
  // PROVIDER HANDLERS
58
  // ============================================================
59
 
60
+ const callGemini = async (config, promptText, history, image) => {
61
+ const genAI = new GoogleGenerativeAI(config.key);
62
+ const model = genAI.getGenerativeModel({ model: config.model });
63
+
64
+ let formattedHistory = history.map(msg => {
65
  const parts = [{ text: msg.text || "" }];
66
  if (msg.image) {
67
  const [mime, data] = msg.image.split(';base64,');
 
70
  return { role: msg.role === 'bot' ? 'model' : 'user', parts };
71
  });
72
 
73
+ // Gemini requires history to start with 'user' role
74
+ const firstUserIdx = formattedHistory.findIndex(h => h.role === 'user');
75
+ if (firstUserIdx > 0) formattedHistory = formattedHistory.slice(firstUserIdx);
76
+ else if (firstUserIdx === -1) formattedHistory = [];
77
+
78
+ const chat = model.startChat({ history: formattedHistory, generationConfig: { maxOutputTokens: 2048 } });
79
+
80
  const messageParts = [{ text: promptText }];
81
  if (image) {
82
  const [mime, data] = image.split(';base64,');
83
  messageParts.push({ inlineData: { mimeType: mime.split(':')[1] || 'image/png', data } });
84
  }
85
 
86
+ const result = await chat.sendMessage(messageParts);
87
+ return result.response.text();
 
88
  };
89
 
90
+ const callOpenAICompat = async (config, promptText, history, image, baseUrl, extraHeaders = {}) => {
91
+ const messages = history.map(msg => ({
92
+ role: msg.role === 'bot' ? 'assistant' : 'user',
93
+ content: msg.text || ""
94
+ }));
 
 
 
 
 
 
 
 
 
95
 
96
  const userContent = image
97
  ? [{ type: "text", text: promptText }, { type: "image_url", image_url: { url: image } }]
98
  : promptText;
99
 
100
  messages.push({ role: "user", content: userContent });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
+ const response = await fetch(`${baseUrl}/chat/completions`, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.key}`, ...extraHeaders },
105
+ body: JSON.stringify({ model: config.model, messages, max_tokens: 2048 })
106
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
+ if (!response.ok) {
109
+ const err = new Error(`HTTP ${response.status}`);
110
+ err.status = response.status;
111
+ throw err;
112
  }
113
+ const data = await response.json();
114
+ return data.choices?.[0]?.message?.content || "No response received.";
115
  };
116
 
117
  // Core failover runner — shared by both exported functions
118
  const runWithFailover = async (promptText, history, image) => {
119
  const configList = buildConfigList();
120
+ if (configList.length === 0) throw new Error("No AI API keys configured in .env");
121
 
122
  for (let i = 0; i < configList.length; i++) {
123
  const config = configList[i];
124
  console.log(`[AI Failover] Trying ${i + 1}/${configList.length}: ${config.provider} (${config.model})`);
125
 
126
  try {
127
+ if (config.provider === 'gemini') {
128
+ return await callGemini(config, promptText, history, image);
129
+ } else if (config.provider === 'openrouter') {
130
+ return await callOpenAICompat(config, promptText, history, image,
131
+ 'https://openrouter.ai/api/v1',
132
+ { 'HTTP-Referer': API_CONFIG.FRONTEND_URL, 'X-Title': 'AI Helpdesk' }
133
+ );
134
+ } else if (config.provider === 'groq') {
135
+ return await callOpenAICompat(config, promptText, history, null, // Groq = text only
136
+ 'https://api.groq.com/openai/v1'
137
+ );
138
+ }
139
  } catch (error) {
140
  const isRateLimit = error.status === 429
141
  || error.message?.includes('429')
Frontend/src/services/api.js CHANGED
@@ -2,7 +2,7 @@ import axios from 'axios';
2
  import { MOCK_TICKETS } from './mockData';
3
  import { API_CONFIG } from '../config';
4
 
5
- const USE_MOCK = API_CONFIG.USE_MOCK;
6
  const API_BASE_URL = API_CONFIG.BACKEND_URL;
7
 
8
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -38,69 +38,38 @@ const setStorage = (key, data) => {
38
  }
39
  };
40
 
41
- // Shared mock logic for createTicket
42
- const createTicketMock = (ticketData) => {
43
- const tickets = getStorage('tickets', MOCK_TICKETS);
44
- const newTicket = {
45
- ticket_id: "TCKT-" + Math.floor(Math.random() * 10000),
46
- status: 'Open',
47
- createdAt: new Date().toISOString(),
48
- ...ticketData,
49
- messages: [
50
- {
51
- sender: 'user',
52
- message: ticketData.description || ticketData.summary || '',
53
- timestamp: new Date().toISOString()
54
- }
55
- ]
56
- };
57
- tickets.unshift(newTicket); // Add to beginning
58
- setStorage('tickets', tickets);
59
- return { data: newTicket };
60
- };
61
-
62
  export const api = {
63
  // Login and Signup have been fully migrated to Supabase via authStore.js
64
  // Ensure that no component tries to use api.login or api.signup anymore.
65
 
 
66
  getTickets: async () => {
67
  if (USE_MOCK) {
68
  await delay(500);
69
  return getStorage('tickets', MOCK_TICKETS);
70
  }
71
- try {
72
- const response = await axios.get(`${API_BASE_URL}/tickets`);
73
- const data = response?.data;
74
-
75
- // Normalize to the mock shape: an array of tickets
76
- if (Array.isArray(data)) return data;
77
- if (data && Array.isArray(data.data)) return data.data;
78
- if (data && Array.isArray(data.tickets)) return data.tickets;
79
-
80
- return data;
81
- } catch (error) {
82
- console.error("Backend unavailable, falling back to mock:", error);
83
- await delay(500);
84
- return getStorage('tickets', MOCK_TICKETS);
85
- }
86
  },
87
 
88
  createTicket: async (ticketData) => {
89
  if (USE_MOCK) {
90
  await delay(800);
91
- return createTicketMock(ticketData);
92
- }
93
- try {
94
- const response = await axios.post(`${API_BASE_URL}/tickets/save`, ticketData);
95
- const created = response?.data;
96
-
97
- // Normalize to mock shape: { data: <createdTicket> }
98
- if (created && created.data) return created;
99
- return { data: created };
100
- } catch (error) {
101
- console.error("Backend unavailable, falling back to mock:", error);
102
- await delay(800);
103
- return createTicketMock(ticketData);
 
 
 
 
104
  }
105
  },
106
 
 
2
  import { MOCK_TICKETS } from './mockData';
3
  import { API_CONFIG } from '../config';
4
 
5
+ const USE_MOCK = true;
6
  const API_BASE_URL = API_CONFIG.BACKEND_URL;
7
 
8
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 
38
  }
39
  };
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  export const api = {
42
  // Login and Signup have been fully migrated to Supabase via authStore.js
43
  // Ensure that no component tries to use api.login or api.signup anymore.
44
 
45
+
46
  getTickets: async () => {
47
  if (USE_MOCK) {
48
  await delay(500);
49
  return getStorage('tickets', MOCK_TICKETS);
50
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  },
52
 
53
  createTicket: async (ticketData) => {
54
  if (USE_MOCK) {
55
  await delay(800);
56
+ const tickets = getStorage('tickets', MOCK_TICKETS);
57
+ const newTicket = {
58
+ ticket_id: "TCKT-" + Math.floor(Math.random() * 10000),
59
+ status: 'Open',
60
+ createdAt: new Date().toISOString(),
61
+ ...ticketData,
62
+ messages: [
63
+ {
64
+ sender: 'user',
65
+ message: ticketData.description || ticketData.summary || '',
66
+ timestamp: new Date().toISOString()
67
+ }
68
+ ]
69
+ };
70
+ tickets.unshift(newTicket); // Add to beginning
71
+ setStorage('tickets', tickets);
72
+ return { data: newTicket };
73
  }
74
  },
75
 
Frontend/src/store/authStore.js CHANGED
@@ -1,40 +1,8 @@
1
  import { create } from 'zustand';
2
  import { persist } from 'zustand/middleware';
3
  import { supabase } from '../lib/supabaseClient';
4
- import { API_CONFIG } from '../config';
5
  import useTicketStore from './ticketStore';
6
 
7
- const BACKEND_URL = API_CONFIG.BACKEND_URL;
8
-
9
- const verifyServerCookieSession = async () => {
10
- try {
11
- const res = await fetch(`${BACKEND_URL}/auth/me`, {
12
- method: 'GET',
13
- credentials: 'include',
14
- headers: { Accept: 'application/json' },
15
- });
16
- if (!res.ok) return null;
17
- const body = await res.json();
18
- return body?.user || null;
19
- } catch (e) {
20
- console.warn('Server cookie session check failed:', e?.message || e);
21
- return null;
22
- }
23
- };
24
-
25
- const mirrorBackendAuth = async (path, payload) => {
26
- try {
27
- await fetch(`${BACKEND_URL}${path}`, {
28
- method: 'POST',
29
- credentials: 'include',
30
- headers: { 'Content-Type': 'application/json' },
31
- body: JSON.stringify(payload),
32
- });
33
- } catch (e) {
34
- console.warn(`Backend auth ${path} failed:`, e?.message || e);
35
- }
36
- };
37
-
38
  let currentUserPromise = null;
39
 
40
  const useAuthStore = create(
@@ -121,13 +89,6 @@ const useAuthStore = create(
121
  currentUserPromise = (async () => {
122
  try {
123
  set({ isCheckingSession: true });
124
- const cookieUser = await verifyServerCookieSession();
125
- if (cookieUser) {
126
- set({ user: cookieUser });
127
- get().getProfile(cookieUser);
128
- return cookieUser;
129
- }
130
-
131
  const { data: { user }, error } = await supabase.auth.getUser();
132
  if (error) throw error;
133
 
@@ -156,8 +117,6 @@ const useAuthStore = create(
156
  set({ loading: true });
157
  console.log("Attempting login for:", email);
158
  try {
159
- await mirrorBackendAuth('/auth/login', { email, password });
160
-
161
  const { data, error } = await supabase.auth.signInWithPassword({
162
  email,
163
  password,
@@ -246,14 +205,6 @@ const useAuthStore = create(
246
  console.log("Starting signup for:", email);
247
 
248
  try {
249
- await mirrorBackendAuth('/auth/signup', {
250
- email,
251
- password,
252
- full_name: fullName,
253
- role,
254
- company,
255
- });
256
-
257
  // 1. Auth Signup with Metadata
258
  console.log("Step 1: Auth.signUp...");
259
  const { data, error } = await supabase.auth.signUp({
@@ -294,15 +245,6 @@ const useAuthStore = create(
294
  logout: async () => {
295
  set({ loading: true });
296
  try {
297
- try {
298
- await fetch(`${BACKEND_URL}/auth/logout`, {
299
- method: 'POST',
300
- credentials: 'include',
301
- });
302
- } catch (e) {
303
- console.warn('Backend cookie logout failed:', e?.message || e);
304
- }
305
-
306
  const { error } = await supabase.auth.signOut();
307
  if (error) throw error;
308
  set({ user: null, profile: null });
@@ -363,7 +305,8 @@ const useAuthStore = create(
363
  {
364
  name: 'auth-storage',
365
  partialize: (state) => ({
366
- // Profile only JWT session lives in HttpOnly cookies (issue #130)
 
367
  profile: state.profile
368
  }),
369
  }
 
1
  import { create } from 'zustand';
2
  import { persist } from 'zustand/middleware';
3
  import { supabase } from '../lib/supabaseClient';
 
4
  import useTicketStore from './ticketStore';
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  let currentUserPromise = null;
7
 
8
  const useAuthStore = create(
 
89
  currentUserPromise = (async () => {
90
  try {
91
  set({ isCheckingSession: true });
 
 
 
 
 
 
 
92
  const { data: { user }, error } = await supabase.auth.getUser();
93
  if (error) throw error;
94
 
 
117
  set({ loading: true });
118
  console.log("Attempting login for:", email);
119
  try {
 
 
120
  const { data, error } = await supabase.auth.signInWithPassword({
121
  email,
122
  password,
 
205
  console.log("Starting signup for:", email);
206
 
207
  try {
 
 
 
 
 
 
 
 
208
  // 1. Auth Signup with Metadata
209
  console.log("Step 1: Auth.signUp...");
210
  const { data, error } = await supabase.auth.signUp({
 
245
  logout: async () => {
246
  set({ loading: true });
247
  try {
 
 
 
 
 
 
 
 
 
248
  const { error } = await supabase.auth.signOut();
249
  if (error) throw error;
250
  set({ user: null, profile: null });
 
305
  {
306
  name: 'auth-storage',
307
  partialize: (state) => ({
308
+ // We keep profile persisted for quick UI transitions,
309
+ // but session is handled by Supabase cookie/localStorage
310
  profile: state.profile
311
  }),
312
  }
Frontend/src/store/ticketStore.js CHANGED
@@ -30,27 +30,6 @@ const useTicketStore = create(
30
  tickets: [...state.tickets, ticket]
31
  };
32
  }),
33
- upsertTicket: (ticket) => set((state) => {
34
- const ticketId = ticket?.id ?? ticket?.ticket_id;
35
- if (!ticketId) return state;
36
-
37
- const exists = state.tickets.some(t => (t.id ?? t.ticket_id) === ticketId);
38
- const tickets = exists
39
- ? state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...ticket } : t)
40
- : [ticket, ...state.tickets];
41
- const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
42
-
43
- return {
44
- tickets,
45
- activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...ticket } : state.activeTicket
46
- };
47
- }),
48
- removeTicket: (ticketId) => set((state) => ({
49
- tickets: state.tickets.filter(t => (t.id ?? t.ticket_id) !== ticketId),
50
- activeTicket: (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId
51
- ? null
52
- : state.activeTicket
53
- })),
54
  updateTicket: (ticketId, updates) => set((state) => {
55
  // eslint-disable-next-line no-unused-vars
56
  const existingTicket = state.tickets.find(t => t.ticket_id === ticketId);
 
30
  tickets: [...state.tickets, ticket]
31
  };
32
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  updateTicket: (ticketId, updates) => set((state) => {
34
  // eslint-disable-next-line no-unused-vars
35
  const existingTicket = state.tickets.find(t => t.ticket_id === ticketId);
Frontend/src/user/components/RecentTickets.jsx CHANGED
@@ -150,11 +150,6 @@ const RecentTickets = () => {
150
  <p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
151
  {ticket.summary || ticket.subject || ticket.description || "No description provided"}
152
  </p>
153
- {ticket?.metadata?.translation?.translated && (
154
- <p style={{ fontSize: '11px', color: '#0369a1', margin: '4px 0 0' }}>
155
- Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
156
- </p>
157
- )}
158
  </td>
159
  <td style={{ padding: '16px 28px' }}>
160
  {getStatusBadge(ticket.status)}
 
150
  <p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
151
  {ticket.summary || ticket.subject || ticket.description || "No description provided"}
152
  </p>
 
 
 
 
 
153
  </td>
154
  <td style={{ padding: '16px 28px' }}>
155
  {getStatusBadge(ticket.status)}
Frontend/src/user/pages/AutoResolveChat.jsx CHANGED
@@ -19,7 +19,6 @@ const AutoResolveChat = () => {
19
  const { aiTicket } = useTicketStore();
20
  const navigate = useNavigate();
21
  const [messages, setMessages] = useState([]);
22
- const [steps, setSteps] = useState([]);
23
  const [isThinking, setIsThinking] = useState(false);
24
  const [isFinal, setIsFinal] = useState(false);
25
  const [inputText, setInputText] = useState('');
@@ -78,7 +77,7 @@ const AutoResolveChat = () => {
78
  }
79
 
80
  if (newSteps.length >= 2) {
81
- setSteps(newSteps);
82
  } else {
83
  const sentences = response
84
  .replace(/\*\*/g, '')
@@ -88,12 +87,7 @@ const AutoResolveChat = () => {
88
  .slice(0, 4);
89
 
90
  if (sentences.length >= 2) {
91
- const sentenceSteps = sentences.map((s, i) => ({
92
- id: i + 1,
93
- task: s,
94
- completed: false
95
- }));
96
- setSteps(sentenceSteps);
97
  } else {
98
  throw new Error("Could not parse steps from AI response.");
99
  }
@@ -203,12 +197,6 @@ const AutoResolveChat = () => {
203
  else recognition.start();
204
  };
205
 
206
- const toggleStep = (stepId) => {
207
- setSteps(prev => prev.map(step =>
208
- step.id === stepId ? { ...step, completed: !step.completed } : step
209
- ));
210
- };
211
-
212
  if (!aiTicket) return null;
213
 
214
  return (
@@ -261,48 +249,6 @@ const AutoResolveChat = () => {
261
  </div>
262
  </div>
263
 
264
- {/* Troubleshooting Steps */}
265
- {steps.length > 0 && (
266
- <div className="px-10 py-6 border-b border-white/40 bg-emerald-50/30">
267
- <div className="flex items-center gap-2 mb-3">
268
- <ListChecks size={16} className="text-emerald-600" />
269
- <h3 className="text-[10px] font-black text-emerald-700 uppercase tracking-[0.2em]">
270
- Troubleshooting Plan
271
- </h3>
272
- </div>
273
- <div className="space-y-2">
274
- {steps.map((step) => (
275
- <button
276
- key={step.id}
277
- onClick={() => toggleStep(step.id)}
278
- className={`w-full text-left flex items-center gap-3 p-3 rounded-xl border transition-all duration-300 ${
279
- step.completed
280
- ? 'bg-emerald-100 border-emerald-200 opacity-60'
281
- : 'bg-white border-slate-100 hover:border-emerald-200 hover:bg-emerald-50/50'
282
- }`}
283
- >
284
- <div className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 transition-all duration-300 ${
285
- step.completed
286
- ? 'bg-emerald-500 text-white'
287
- : 'bg-slate-100 text-slate-400'
288
- }`}>
289
- {step.completed ? (
290
- <CheckCircle2 size={14} />
291
- ) : (
292
- <span className="text-[10px] font-black">{step.id}</span>
293
- )}
294
- </div>
295
- <span className={`text-[13px] font-bold leading-snug transition-all duration-300 ${
296
- step.completed ? 'text-slate-400 line-through' : 'text-slate-700'
297
- }`}>
298
- {step.task}
299
- </span>
300
- </button>
301
- ))}
302
- </div>
303
- </div>
304
- )}
305
-
306
  {/* Chat Messages */}
307
  <div
308
  ref={scrollRef}
 
19
  const { aiTicket } = useTicketStore();
20
  const navigate = useNavigate();
21
  const [messages, setMessages] = useState([]);
 
22
  const [isThinking, setIsThinking] = useState(false);
23
  const [isFinal, setIsFinal] = useState(false);
24
  const [inputText, setInputText] = useState('');
 
77
  }
78
 
79
  if (newSteps.length >= 2) {
80
+ // Steps parsed successfully; welcome message sent below
81
  } else {
82
  const sentences = response
83
  .replace(/\*\*/g, '')
 
87
  .slice(0, 4);
88
 
89
  if (sentences.length >= 2) {
90
+ // Plan parsed but not rendered; welcome message sent below
 
 
 
 
 
91
  } else {
92
  throw new Error("Could not parse steps from AI response.");
93
  }
 
197
  else recognition.start();
198
  };
199
 
 
 
 
 
 
 
200
  if (!aiTicket) return null;
201
 
202
  return (
 
249
  </div>
250
  </div>
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  {/* Chat Messages */}
253
  <div
254
  ref={scrollRef}
Frontend/src/user/pages/CreateTicket.jsx CHANGED
@@ -21,6 +21,7 @@ import { motion, AnimatePresence } from 'framer-motion';
21
  import { Button } from "../../components/ui/button";
22
  import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../components/ui/card";
23
  import { Textarea } from "../../components/ui/textarea";
 
24
  import { translateText, SUPPORTED_LANGUAGES } from '../../services/translationService';
25
  import TemplateSelector from '../components/TemplateSelector';
26
  import TemplateForm from '../components/TemplateForm';
@@ -110,7 +111,6 @@ const CreateTicket = () => {
110
  const processOCR = async (imageFile) => {
111
  setIsOcrLoading(true);
112
  try {
113
- const { default: Tesseract } = await import('tesseract.js');
114
  const { data: { text } } = await Tesseract.recognize(imageFile, 'eng');
115
  setExtractedOCR(text.trim());
116
  } catch (err) {
 
21
  import { Button } from "../../components/ui/button";
22
  import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../components/ui/card";
23
  import { Textarea } from "../../components/ui/textarea";
24
+ import Tesseract from 'tesseract.js';
25
  import { translateText, SUPPORTED_LANGUAGES } from '../../services/translationService';
26
  import TemplateSelector from '../components/TemplateSelector';
27
  import TemplateForm from '../components/TemplateForm';
 
111
  const processOCR = async (imageFile) => {
112
  setIsOcrLoading(true);
113
  try {
 
114
  const { data: { text } } = await Tesseract.recognize(imageFile, 'eng');
115
  setExtractedOCR(text.trim());
116
  } catch (err) {
Frontend/src/user/pages/MyTickets.jsx CHANGED
@@ -120,14 +120,6 @@ function MyTickets() {
120
  return 'text-gray-600';
121
  };
122
 
123
- const getTranslationInfo = (ticket) => {
124
- const t = ticket?.metadata?.translation;
125
- if (!t?.translated) return null;
126
- return {
127
- sourceLanguageName: t.source_language_name || t.source_language || 'Unknown',
128
- };
129
- };
130
-
131
  return (
132
  <main className="flex-1 max-w-[1200px] w-full mx-auto px-6 py-10 flex flex-col gap-8">
133
  {/* Header section */}
@@ -325,11 +317,6 @@ function MyTickets() {
325
  <p className="text-sm font-semibold text-gray-900 truncate group-hover:text-emerald-700 transition-colors">
326
  {ticket.summary || ticket.subject || ticket.description || "No subject"}
327
  </p>
328
- {getTranslationInfo(ticket) && (
329
- <p className="text-[10px] text-slate-500 mt-1">
330
- Translated from {getTranslationInfo(ticket).sourceLanguageName}
331
- </p>
332
- )}
333
  </td>
334
  <td className="px-6 py-4">
335
  <span className="text-sm font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-md">
 
120
  return 'text-gray-600';
121
  };
122
 
 
 
 
 
 
 
 
 
123
  return (
124
  <main className="flex-1 max-w-[1200px] w-full mx-auto px-6 py-10 flex flex-col gap-8">
125
  {/* Header section */}
 
317
  <p className="text-sm font-semibold text-gray-900 truncate group-hover:text-emerald-700 transition-colors">
318
  {ticket.summary || ticket.subject || ticket.description || "No subject"}
319
  </p>
 
 
 
 
 
320
  </td>
321
  <td className="px-6 py-4">
322
  <span className="text-sm font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-md">
Frontend/src/user/pages/TicketDetail.jsx CHANGED
@@ -22,7 +22,6 @@ const TicketDetail = () => {
22
  const [isReopening, setIsReopening] = useState(false);
23
  const [showCsat, setShowCsat] = useState(false);
24
  const [csatHasBeenDismissed, setCsatHasBeenDismissed] = useState(false);
25
- const [showOriginalText, setShowOriginalText] = useState(false);
26
 
27
  useEffect(() => {
28
  window.scrollTo(0, 0);
@@ -123,10 +122,6 @@ const TicketDetail = () => {
123
  const solutionSteps = Array.isArray(ticket.solution_steps) ? ticket.solution_steps : [];
124
  const isAutoResolved = ticket.auto_resolve === true;
125
  const confidenceScore = ticket.metadata?.confidence ?? ticket.routing_confidence ?? 0.92;
126
- const translationMeta = ticket.metadata?.translation;
127
- const originalTextMeta = ticket.metadata?.original_text;
128
- const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
129
- const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
130
 
131
 
132
  const handleReopen = async () => {
@@ -189,27 +184,6 @@ const TicketDetail = () => {
189
 
190
  {/* LEFT SIDE (Main Content) */}
191
  <div className="lg:col-span-2 flex flex-col gap-6">
192
- {isTranslated && (
193
- <Card className="p-4 rounded-2xl border border-sky-100 bg-sky-50/70 shadow-sm">
194
- <div className="flex items-center justify-between gap-3">
195
- <p className="text-sm font-semibold text-sky-900">
196
- Translated from {sourceLanguageName}
197
- </p>
198
- <button
199
- type="button"
200
- onClick={() => setShowOriginalText(prev => !prev)}
201
- className="text-xs font-bold text-sky-700 hover:text-sky-900"
202
- >
203
- {showOriginalText ? "View English" : "View Original"}
204
- </button>
205
- </div>
206
- {showOriginalText && (
207
- <p className="mt-3 text-sm text-slate-700 bg-white border border-sky-100 rounded-lg px-3 py-2">
208
- {originalTextMeta?.description}
209
- </p>
210
- )}
211
- </Card>
212
- )}
213
 
214
  {/* Card 1: Ticket Timeline */}
215
  <Card className="p-6 sm:p-8 rounded-2xl border border-gray-100 shadow-sm bg-white">
 
22
  const [isReopening, setIsReopening] = useState(false);
23
  const [showCsat, setShowCsat] = useState(false);
24
  const [csatHasBeenDismissed, setCsatHasBeenDismissed] = useState(false);
 
25
 
26
  useEffect(() => {
27
  window.scrollTo(0, 0);
 
122
  const solutionSteps = Array.isArray(ticket.solution_steps) ? ticket.solution_steps : [];
123
  const isAutoResolved = ticket.auto_resolve === true;
124
  const confidenceScore = ticket.metadata?.confidence ?? ticket.routing_confidence ?? 0.92;
 
 
 
 
125
 
126
 
127
  const handleReopen = async () => {
 
184
 
185
  {/* LEFT SIDE (Main Content) */}
186
  <div className="lg:col-span-2 flex flex-col gap-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
  {/* Card 1: Ticket Timeline */}
189
  <Card className="p-6 sm:p-8 rounded-2xl border border-gray-100 shadow-sm bg-white">
Frontend/vite.config.js CHANGED
@@ -15,6 +15,6 @@ export default defineConfig({
15
  },
16
  },
17
  build: {
18
- sourcemap: 'hidden'
19
  }
20
  })
 
15
  },
16
  },
17
  build: {
18
+ sourcemap: true
19
  }
20
  })
MobileApp/src/screens/auth/LoginScreen.js CHANGED
@@ -5,7 +5,6 @@ import {
5
  ScrollView, StatusBar, Animated,
6
  } from 'react-native';
7
  import { supabase } from '../../lib/supabase';
8
- import { backendLogin, backendLogout } from '../../lib/authBackend';
9
  import { COLORS, SHADOWS } from '../../styles/theme';
10
  import { Lock, Mail, Eye, EyeOff, Zap, ArrowRight, ShieldCheck } from 'lucide-react-native';
11
  import { LinearGradient } from 'expo-linear-gradient';
@@ -53,7 +52,6 @@ const LoginScreen = () => {
53
  }
54
  setLoading(true);
55
  try {
56
- await backendLogin(email, password);
57
  const { data, error } = await supabase.auth.signInWithPassword({ email, password });
58
  if (error) throw error;
59
 
 
5
  ScrollView, StatusBar, Animated,
6
  } from 'react-native';
7
  import { supabase } from '../../lib/supabase';
 
8
  import { COLORS, SHADOWS } from '../../styles/theme';
9
  import { Lock, Mail, Eye, EyeOff, Zap, ArrowRight, ShieldCheck } from 'lucide-react-native';
10
  import { LinearGradient } from 'expo-linear-gradient';
 
52
  }
53
  setLoading(true);
54
  try {
 
55
  const { data, error } = await supabase.auth.signInWithPassword({ email, password });
56
  if (error) throw error;
57
 
MobileApp/src/screens/user/ProfileScreen.js CHANGED
@@ -5,7 +5,6 @@ import {
5
  } from 'react-native';
6
  import { SafeAreaView } from 'react-native-safe-area-context';
7
  import { supabase } from '../../lib/supabase';
8
- import { backendLogout } from '../../lib/authBackend';
9
  import { COLORS, SHADOWS } from '../../styles/theme';
10
  import {
11
  User, Mail, Building2, ShieldCheck, Calendar, Ticket, Zap,
@@ -227,7 +226,6 @@ const ProfileScreen = () => {
227
  const handleLogout = async () => {
228
  try {
229
  Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
230
- await backendLogout();
231
  // Forcefully wipe all Supabase session keys from AsyncStorage first to trigger instant navigation resetting
232
  const keys = await AsyncStorage.getAllKeys();
233
  const supabaseKeys = keys.filter(k => k.startsWith('sb-') || k.includes('supabase'));
@@ -238,7 +236,6 @@ const ProfileScreen = () => {
238
  } catch (e) {
239
  console.warn("Logout error, forcing full wipe:", e);
240
  await AsyncStorage.clear();
241
- await supabase.auth.signOut();
242
  }
243
  };
244
 
 
5
  } from 'react-native';
6
  import { SafeAreaView } from 'react-native-safe-area-context';
7
  import { supabase } from '../../lib/supabase';
 
8
  import { COLORS, SHADOWS } from '../../styles/theme';
9
  import {
10
  User, Mail, Building2, ShieldCheck, Calendar, Ticket, Zap,
 
226
  const handleLogout = async () => {
227
  try {
228
  Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
 
229
  // Forcefully wipe all Supabase session keys from AsyncStorage first to trigger instant navigation resetting
230
  const keys = await AsyncStorage.getAllKeys();
231
  const supabaseKeys = keys.filter(k => k.startsWith('sb-') || k.includes('supabase'));
 
236
  } catch (e) {
237
  console.warn("Logout error, forcing full wipe:", e);
238
  await AsyncStorage.clear();
 
239
  }
240
  };
241
 
README.md CHANGED
@@ -58,9 +58,7 @@ pinned: false
58
 
59
  ## 🌟 GSSoC '26 Contributor & Community Campaign
60
 
61
- We are extremely proud to be part of the **GirlScript Summer of Code (GSSoC) 2026**! To ensure high-quality contributions and maximum rewards for both developers and mentors, please review our official [GSSoC Mentorship Guide & Review Standard](MENTORSHIP.md).
62
-
63
- To support the project and get real-time open-source project updates, please make sure you participate in our community campaign:
64
 
65
  <div align="center">
66
 
 
58
 
59
  ## 🌟 GSSoC '26 Contributor & Community Campaign
60
 
61
+ We are extremely proud to be part of the **GirlScript Summer of Code (GSSoC) 2026**! To support the project and get real-time open-source project updates, please make sure you participate in our community campaign:
 
 
62
 
63
  <div align="center">
64
 
backend/.env.example CHANGED
@@ -1,106 +1,18 @@
1
- # =============================================================================
2
- # HELPDESK.AI Backend — Environment Variable Reference
3
- # =============================================================================
4
- # Copy this file to `backend/.env` and fill in the values appropriate for your
5
- # environment. NEVER commit a populated `.env` file to source control.
6
- #
7
- # All variables are read via `os.environ.get(...)` / `os.getenv(...)` in the
8
- # backend codebase. Defaults shown below match the values used in code.
9
- # =============================================================================
10
-
11
-
12
- # -----------------------------------------------------------------------------
13
- # Supabase (Database + Auth + Storage)
14
- # -----------------------------------------------------------------------------
15
- # Required for: ticket persistence, RAG knowledge base, SLA escalation,
16
- # auto-close cron job, notification routing, company settings seeding.
17
- #
18
- # Service-role key is required for backend (bypasses RLS). NEVER expose this
19
- # key to the frontend — use the anon key on the client side instead.
20
- SUPABASE_URL=https://YOUR-PROJECT-REF.supabase.co
21
- SUPABASE_SERVICE_KEY=
22
- # Anon key — required for /auth/login cookie bridge (issue #130). Safe for backend proxy only.
23
- SUPABASE_ANON_KEY=
24
- # Some newer services (auto_close, notification_routing, sla_service)
25
- # also accept SUPABASE_SERVICE_ROLE_KEY as an alias. Set both for safety.
26
- SUPABASE_SERVICE_ROLE_KEY=
27
-
28
-
29
- # -----------------------------------------------------------------------------
30
- # Google Gemini (LLM)
31
- # -----------------------------------------------------------------------------
32
- # Required for: auto-resolve chat, summary generation, advanced classification.
33
- # If unset, GeminiService will initialize in disabled mode and downstream
34
- # endpoints that depend on it will return graceful fallback responses.
35
- GEMINI_API_KEY=
36
 
 
 
 
 
37
 
38
- # -----------------------------------------------------------------------------
39
- # AI Model Artifacts
40
- # -----------------------------------------------------------------------------
41
- # Path to a local sentence-transformer model directory used by RAG and
42
- # duplicate detection. If unset, the services will attempt to download the
43
- # default model on first run (requires internet access).
44
  SENTENCE_TRANSFORMER_MODEL_PATH=
45
 
46
-
47
- # -----------------------------------------------------------------------------
48
- # Startup / Health-Check Behaviour
49
- # -----------------------------------------------------------------------------
50
- # Controls strict startup validation. When `0` (default), the backend will
51
- # REFUSE to start if core classifier assets are missing — this prevents the
52
- # silent "Unknown" classification fallback documented in ISSUE_DEBUG_FINDINGS.
53
- # Set to `1` only for local development without the full model bundle.
54
- ALLOW_DEGRADED_STARTUP=0
55
-
56
- # When `true`, startup will hard-fail if Supabase credentials are missing.
57
- # Default `false` allows the backend to run in "no-persistence" mode for tests.
58
  REQUIRE_SUPABASE=false
59
-
60
- # Slack Alerts
61
- SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
62
-
63
- # -----------------------------------------------------------------------------
64
- # Health-Check Probe (Docker / Kubernetes)
65
- # -----------------------------------------------------------------------------
66
- HEALTHCHECK_URL=http://127.0.0.1:7860/ready
67
- HEALTHCHECK_TIMEOUT_SECONDS=3
68
-
69
-
70
- # -----------------------------------------------------------------------------
71
- # SLA Escalation Background Worker
72
- # -----------------------------------------------------------------------------
73
- SLA_ESCALATION_ENABLED=true
74
- SLA_ESCALATION_INTERVAL_SECONDS=300
75
-
76
-
77
- # -----------------------------------------------------------------------------
78
- # Auto-Close Cron Job (Issue #41)
79
- # -----------------------------------------------------------------------------
80
- AUTO_CLOSE_ENABLED=true
81
- AUTO_CLOSE_DAYS=7
82
- # Standard 5-field cron expression. Default: 02:00 UTC every day.
83
- AUTO_CLOSE_CRON_SCHEDULE=0 2 * * *
84
-
85
-
86
- # -----------------------------------------------------------------------------
87
- # Notification Routing Middleware (Issue #41)
88
- # -----------------------------------------------------------------------------
89
- # Accepted values: debug, info, warning
90
- NOTIFICATION_ROUTING_LOG_LEVEL=info
91
-
92
-
93
- # -----------------------------------------------------------------------------
94
- # Generic Environment Flag
95
- # -----------------------------------------------------------------------------
96
- # Set to `development` to enable extra debug logging in some services.
97
- ENV=production
98
-
99
-
100
- # -----------------------------------------------------------------------------
101
- # Redis Inference Cache (Issue #131)
102
- # -----------------------------------------------------------------------------
103
- # When true, cache DistilBERT classifications and sentence-transformer embeddings.
104
- USE_REDIS_CACHE=false
105
- REDIS_URL=redis://127.0.0.1:6379/0
106
- REDIS_CACHE_TTL_SECONDS=3600
 
1
+ # Supabase Configuration
2
+ SUPABASE_URL=https://your-project.supabase.co
3
+ SUPABASE_SERVICE_KEY=your-service-key
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
+ # Startup Mode
6
+ # Set ALLOW_DEGRADED_STARTUP=1 to allow backend startup even if duplicate/RAG models fail to load
7
+ # Useful for offline/dev environments where model downloads may not be available
8
+ ALLOW_DEGRADED_STARTUP=0
9
 
10
+ # Sentence Transformers Model Path
11
+ # Provide a local path to the sentence-transformers model to avoid downloading from HuggingFace
12
+ # If not set, model will be downloaded from HuggingFace (requires internet)
13
+ # Example: ./models/all-MiniLM-L6-v2 or /opt/models/all-MiniLM-L6-v2
 
 
14
  SENTENCE_TRANSFORMER_MODEL_PATH=
15
 
16
+ # Readiness Check
17
+ # Set REQUIRE_SUPABASE=true to include Supabase configuration in the strict readiness gate
 
 
 
 
 
 
 
 
 
 
18
  REQUIRE_SUPABASE=false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/Dockerfile CHANGED
@@ -1,40 +1,36 @@
1
- # syntax=docker/dockerfile:1
 
2
 
3
- FROM python:3.10-slim AS builder
4
 
5
- WORKDIR /build
6
- RUN apt-get update && apt-get install -y --no-install-recommends \
 
 
 
 
 
7
  git \
8
  && rm -rf /var/lib/apt/lists/*
9
 
 
10
  COPY requirements.txt .
11
- RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
12
-
13
- FROM python:3.10-slim AS runtime
14
 
15
- LABEL org.opencontainers.image.title="helpdesk-backend"
16
- LABEL org.opencontainers.image.description="HELPDESK.AI FastAPI backend (multi-stage)"
17
 
18
- WORKDIR /app
19
-
20
- RUN apt-get update && apt-get install -y --no-install-recommends \
21
- libgl1 \
22
- libglib2.0-0 \
23
- && rm -rf /var/lib/apt/lists/* \
24
- && useradd --create-home --shell /usr/sbin/nologin appuser
25
-
26
- COPY --from=builder /install /usr/local
27
  COPY . /app/backend
28
 
29
- ENV PYTHONPATH=/app \
30
- PYTHONDONTWRITEBYTECODE=1 \
31
- PYTHONUNBUFFERED=1
32
 
 
33
  EXPOSE 7860
34
 
35
  HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
36
  CMD ["python", "backend/healthcheck.py"]
37
 
38
- USER appuser
39
-
40
  CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.10-slim
3
 
4
+ LABEL version="1.1.1" rebuild_trigger="2026-03-08-2032"
5
 
6
+ # Set the working directory to /app
7
+ WORKDIR /app
8
+
9
+ # Install system dependencies required for EasyOCR and OpenCV
10
+ RUN apt-get update && apt-get install -y \
11
+ libgl1 \
12
+ libglib2.0-0 \
13
  git \
14
  && rm -rf /var/lib/apt/lists/*
15
 
16
+ # Copy the requirements file into the container
17
  COPY requirements.txt .
 
 
 
18
 
19
+ # Install dependencies (no-cache-dir keeps the docker image smaller)
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
 
22
+ # Copy all the remaining files into the container as a 'backend' directory
23
+ # This allows absolute imports like 'from backend.services...' to work perfectly
 
 
 
 
 
 
 
24
  COPY . /app/backend
25
 
26
+ # Tell Python where to look for modules (so it can find the 'backend' folder)
27
+ ENV PYTHONPATH=/app
 
28
 
29
+ # Expose port 7860 (Hugging Face Spaces default)
30
  EXPOSE 7860
31
 
32
  HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
33
  CMD ["python", "backend/healthcheck.py"]
34
 
35
+ # Run the FastAPI server via Uvicorn
 
36
  CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
backend/main.py CHANGED
@@ -8,7 +8,6 @@ import os
8
  import sys
9
  import uuid
10
  import json
11
- import re
12
  import datetime
13
  import traceback
14
  import warnings
@@ -25,7 +24,8 @@ from slowapi import Limiter, _rate_limit_exceeded_handler
25
  from slowapi.util import get_remote_address
26
  from slowapi.errors import RateLimitExceeded
27
  from fastapi.middleware.cors import CORSMiddleware
28
- from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
 
29
  from fastapi.encoders import jsonable_encoder
30
  import asyncio
31
  from pathlib import Path
@@ -36,13 +36,6 @@ from dotenv import load_dotenv
36
  env_path = Path(__file__).parent / '.env'
37
  load_dotenv(dotenv_path=env_path)
38
 
39
- # CI smoke tests allow degraded startup so the app can import without heavy ML assets.
40
- ALLOW_DEGRADED_STARTUP = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
41
-
42
-
43
- def _startup_fatal(message: str) -> None:
44
- print(f"[Startup-FATAL] {message}")
45
-
46
  # Initialize Supabase Client (Service Role for backend bypass)
47
  try:
48
  from supabase import create_client, Client
@@ -64,14 +57,16 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")
64
  from backend.services.classifier_service import ClassifierService
65
  from backend.services.classifier_v2 import classifier_v2
66
  from backend.services.classifier_v3 import classifier_v3 # V3 Power Model
67
- from backend.services.audit_service import AuditLogService, AuditLogAccessError
68
- from backend.services.onnx_service import onnx_classifier
69
  from backend.services.ner_service import NERService
70
  from backend.services.duplicate_service import DuplicateService
71
  from backend.services.rag_service import RagService
72
- from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
73
- from backend.services.redis_cache import redis_cache
74
- from backend.auth_cookie import router as auth_cookie_router, get_current_user # noqa: F401
 
 
 
 
75
 
76
 
77
  # ---------------------------------------------------------------------------
@@ -120,62 +115,13 @@ def detect_semantic_duplicate(text: str, *, company_id: str | None, threshold: f
120
  duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
121
  duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
122
  return duplicate_result
123
-
124
-
125
- def classify_ticket_text(text: str) -> dict:
126
- """Run the local classifier cascade with ONNX as the offline fallback path."""
127
- cached = redis_cache.get_classification(text)
128
- if cached:
129
- return cached
130
-
131
- result = _classify_ticket_text_uncached(text)
132
- redis_cache.set_classification(text, result)
133
- return result
134
-
135
-
136
- def _classify_ticket_text_uncached(text: str) -> dict:
137
- try:
138
- classification_v3_res = classifier_v3.predict(text)
139
- if "error" not in classification_v3_res:
140
- cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
141
- sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
142
- pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
143
- conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
144
-
145
- from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
146
- return {
147
- "category": cat,
148
- "subcategory": sub,
149
- "priority": pri,
150
- "auto_resolve": sub in AUTO_RESOLVE_SUBS,
151
- "assigned_team": TEAM_MAP.get(cat, "General Support"),
152
- "confidence": float(conf),
153
- }
154
- except Exception:
155
- traceback.print_exc()
156
-
157
- try:
158
- onnx_result = onnx_classifier.predict(text)
159
- if onnx_result:
160
- return onnx_result
161
- except Exception as error:
162
- print(f"[ONNX] Fallback classification skipped: {error}")
163
-
164
- try:
165
- return classifier_service.predict(text)
166
- except Exception:
167
- traceback.print_exc()
168
- return {
169
- "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
170
- "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
171
- }
172
-
173
  class TicketRequest(BaseModel):
174
  text: str
175
  image_base64: str = ""
176
  image_text: str = "" # Keep for backward compatibility
177
  user_id: str | None = None
178
  company: str | None = None
 
179
  image_url: str | None = None
180
  confidence_threshold: float = 0.20
181
  duplicate_sensitivity: float = 0.85
@@ -195,6 +141,10 @@ class TicketSaveRequest(BaseModel):
195
  image_url: str | None = None
196
  company: str | None = None
197
  company_id: str | None = None
 
 
 
 
198
  sla_breach_at: str
199
  sla_status: str | None = None
200
  escalation_level: int = 0
@@ -210,6 +160,8 @@ class TicketSaveRequest(BaseModel):
210
  class DuplicateInfo(BaseModel):
211
  is_duplicate: bool
212
  duplicate_ticket_id: str | None = None
 
 
213
  similarity: float = 0.0
214
 
215
 
@@ -231,6 +183,8 @@ class TicketResponse(BaseModel):
231
  entities: list[EntityInfo]
232
  duplicate_ticket: DuplicateInfo
233
  confidence: float
 
 
234
  needs_review: bool = False
235
  reasoning: str = ""
236
  decision_factors: list[str] = []
@@ -240,10 +194,6 @@ class TicketResponse(BaseModel):
240
  timeline: dict = {} # Map of step_name: timestamp
241
  env_metadata: dict = {} # IP, Hostname, Browser/OS
242
  sla_breach_at: str | None = None
243
- original_text: str | None = None
244
- source_language: str = "en"
245
- source_language_name: str = "English"
246
- was_translated: bool = False
247
  version: str = "2.1.0-Neural-Diagnostic"
248
 
249
 
@@ -271,24 +221,6 @@ class TicketRecord(BaseModel):
271
  timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
272
 
273
 
274
- class AuditLogProfile(BaseModel):
275
- full_name: str | None = None
276
- email: str | None = None
277
- profile_picture: str | None = None
278
-
279
-
280
- class AuditLogRecord(BaseModel):
281
- id: str
282
- ticket_id: str
283
- company_id: str
284
- performed_by: str | None = None
285
- action: str
286
- old_value: dict | list | str | None = None
287
- new_value: dict | list | str | None = None
288
- created_at: str
289
- performed_by_profile: AuditLogProfile | None = None
290
-
291
-
292
  # --- In-Memory Database (to be replaced with SQL later) ---
293
  TICKETS_DB: list[TicketRecord] = []
294
 
@@ -311,8 +243,6 @@ classifier_service = ClassifierService()
311
  ner_service = NERService()
312
  duplicate_service = DuplicateService()
313
  rag_service = RagService()
314
- sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
315
- semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
316
 
317
  try:
318
  from backend.services.gemini_service import GeminiService
@@ -326,82 +256,6 @@ try:
326
  except ImportError:
327
  ocr_service = None
328
 
329
- LANGUAGE_NAMES = {
330
- "en": "English",
331
- "es": "Spanish",
332
- "de": "German",
333
- "hi": "Hindi",
334
- "fr": "French",
335
- "it": "Italian",
336
- "pt": "Portuguese",
337
- "ja": "Japanese",
338
- "ko": "Korean",
339
- "zh": "Chinese",
340
- "ar": "Arabic",
341
- "ru": "Russian",
342
- }
343
-
344
- def _heuristic_language_detection(text: str) -> dict:
345
- sample = (text or "").strip()
346
- if not sample:
347
- return {"code": "en", "name": "English"}
348
- ascii_chars = sum(1 for c in sample if ord(c) < 128)
349
- ratio = ascii_chars / max(len(sample), 1)
350
- if ratio > 0.97:
351
- return {"code": "en", "name": "English"}
352
- return {"code": "unknown", "name": "Unknown"}
353
-
354
- def detect_and_translate_ticket_text(text: str) -> dict:
355
- original_text = (text or "").strip()
356
- if not original_text:
357
- return {
358
- "text_for_analysis": text or "",
359
- "source_language": "en",
360
- "source_language_name": "English",
361
- "was_translated": False,
362
- "original_text": "",
363
- "metadata":{},
364
- }
365
-
366
- detected = _heuristic_language_detection(original_text)
367
- if gemini_service and getattr(gemini_service, "_initialized", False):
368
- detected = gemini_service.detect_language(original_text)
369
-
370
- source_code = str(detected.get("code", "en")).lower()
371
- source_name = detected.get("name") or LANGUAGE_NAMES.get(source_code, source_code.upper())
372
- if source_code in ("en", "eng"):
373
- return {
374
- "text_for_analysis": original_text,
375
- "source_language": "en",
376
- "source_language_name": "English",
377
- "was_translated": False,
378
- "original_text": original_text,
379
- "metadata":{},
380
- }
381
-
382
- translated_text = original_text
383
- if gemini_service and getattr(gemini_service, "_initialized", False):
384
- translated_text = gemini_service.translate_to_english(original_text, source_name)
385
-
386
- if not translated_text or translated_text.strip() == original_text:
387
- return {
388
- "text_for_analysis": original_text,
389
- "source_language": source_code,
390
- "source_language_name": source_name,
391
- "was_translated": False,
392
- "original_text": original_text,
393
- "metadata":{},
394
- }
395
-
396
- return {
397
- "text_for_analysis": translated_text.strip(),
398
- "source_language": source_code,
399
- "source_language_name": source_name,
400
- "was_translated": True,
401
- "original_text": original_text,
402
- "metadata":{},
403
- }
404
-
405
 
406
  # ---------------------------------------------------------------------------
407
  # Lifespan (startup / shutdown)
@@ -410,10 +264,6 @@ def detect_and_translate_ticket_text(text: str) -> dict:
410
  async def lifespan(app: FastAPI):
411
  """Load all models at startup."""
412
  print("[Startup] Loading AI models ...")
413
- try:
414
- redis_cache.connect()
415
- except Exception as e:
416
- print(f"[WARNING] Redis cache not available: {e}")
417
  try:
418
  classifier_service.load()
419
  except FileNotFoundError as e:
@@ -430,39 +280,50 @@ async def lifespan(app: FastAPI):
430
  rag_service.load()
431
  except Exception as e:
432
  print(f"[WARNING] RAG service not loaded: {e}")
433
- try:
434
- onnx_classifier.load()
435
- except Exception as e:
436
- print(f"[WARNING] ONNX classifier fallback not loaded: {e}")
437
 
438
  if gemini_service:
439
  print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
440
  else:
441
  print("[Startup] Gemini Service: NOT LOADED (Import failed)")
442
 
443
- # Wire services with supabase client
444
- sla_engine.supabase = supabase
445
- semantic_dupe_service.supabase = supabase
446
-
447
- # Pre-load embedding model so first ticket save is fast
448
  try:
449
- semantic_dupe_service.load()
450
- print(f"[Startup] Semantic Duplicate Detection: {'Loaded' if semantic_dupe_service._loaded else 'Failed (model missing)'}")
451
- except Exception as e:
452
- print(f"[Startup] Semantic Duplicate Detection load error: {e}")
453
- print(f"[Startup] SLA Engine: {'Initialized' if supabase else 'Offline (no DB)'}")
454
 
455
- # Start background SLA checker as an async task (every 5 minutes)
456
- if supabase:
457
- from backend.sla_checker import sla_checker_loop_async
458
- asyncio.create_task(sla_checker_loop_async(supabase, interval_seconds=300))
459
- print("[Startup] SLA background checker started (interval=300s)")
460
 
461
- print("[Startup] Classifier V2 Shadow: Ready.")
462
- print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
463
- print("[Startup] Ready.")
464
- yield
465
- print("[Shutdown] Cleaning up ...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
 
467
 
468
  # ---------------------------------------------------------------------------
@@ -493,8 +354,6 @@ app.add_middleware(
493
  allow_headers=["*"],
494
  )
495
 
496
- app.include_router(auth_cookie_router)
497
-
498
 
499
  # ---------------------------------------------------------------------------
500
  # Root & Health check
@@ -581,6 +440,12 @@ async def root():
581
  """
582
 
583
 
 
 
 
 
 
 
584
  @app.get("/health", response_model=HealthResponse)
585
  async def health_check():
586
  return HealthResponse(
@@ -593,18 +458,29 @@ async def health_check():
593
  @app.get("/ready", response_model=ReadinessResponse)
594
  async def readiness_check():
595
  require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
 
 
596
  checks = {
597
  "api": True,
598
  "classifier_loaded": classifier_service._loaded,
599
  "ner_loaded": ner_service._loaded,
600
- "duplicate_index_loaded": duplicate_service._loaded,
601
- "rag_loaded": rag_service._loaded,
602
  }
603
  if require_supabase:
604
  checks["supabase_configured"] = supabase is not None
605
 
606
- if all(checks.values()):
607
- return ReadinessResponse(status="ready", checks=checks)
 
 
 
 
 
 
 
 
 
608
 
609
  return JSONResponse(
610
  status_code=503,
@@ -745,6 +621,31 @@ async def get_tickets(company_id: str | None = None):
745
  res = query.execute()
746
  return res.data
747
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  @app.post("/tickets/save")
749
  async def save_ticket(request_body: TicketSaveRequest):
750
  """
@@ -755,80 +656,64 @@ async def save_ticket(request_body: TicketSaveRequest):
755
  raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
756
 
757
  logger = logging.getLogger(__name__)
758
- final_data = request_body.model_dump()
759
- original_subject = final_data.get("subject", "") or ""
760
- original_description = final_data.get("description", "") or ""
761
-
762
- # Detect language and translate subject/description into English before downstream routing/indexing.
763
- translation_probe_text = (original_description.strip() or original_subject.strip())
764
- translation_ctx = detect_and_translate_ticket_text(translation_probe_text)
765
- metadata = final_data.get("metadata") or {}
766
- if translation_ctx["was_translated"]:
767
- translated_subject = gemini_service.translate_to_english(original_subject, translation_ctx["source_language_name"]) if original_subject else original_subject
768
- translated_description = gemini_service.translate_to_english(original_description, translation_ctx["source_language_name"]) if original_description else original_description
769
- final_data["subject"] = translated_subject or original_subject
770
- final_data["description"] = translated_description or original_description
771
- metadata["original_text"] = {
772
- "subject": original_subject,
773
- "description": original_description,
774
- }
775
- metadata["translation"] = {
776
- "translated": bool(translation_ctx["was_translated"]),
777
- "source_language": translation_ctx["source_language"],
778
- "source_language_name": translation_ctx["source_language_name"],
779
- }
780
- final_data["metadata"] = metadata
781
-
782
- # Backfill SLA deadlines/status when the client omits or sends empty values.
783
- priority_key = str(final_data.get("priority") or "medium").lower().strip()
784
- now_utc = datetime.datetime.now(datetime.timezone.utc)
785
-
786
- if not str(final_data.get("sla_breach_at") or "").strip():
787
- final_data["sla_breach_at"] = compute_sla_breach_at(priority_key, now_utc)
788
-
789
- if not str(final_data.get("sla_response_due_at") or "").strip():
790
- policy = get_sla_policy(priority_key)
791
- response_hours = max(1, int(round(float(policy["max_hours"]) * 0.25)))
792
- response_due_at = now_utc + datetime.timedelta(hours=response_hours)
793
- final_data["sla_response_due_at"] = response_due_at.isoformat()
794
-
795
- if not str(final_data.get("sla_status") or "").strip():
796
- final_data["sla_status"] = "ACTIVE"
797
- # Resolve tenant linkage from user profile with authorization validation.
798
- profile = {}
799
- if request_body.user_id:
800
- try:
801
- profile_res = (
802
- supabase.table("profiles")
803
- .select("company_id, company")
804
- .eq("id", request_body.user_id)
805
- .single()
806
- .execute()
807
- )
808
- profile = profile_res.data or {}
809
- if not profile:
810
- raise HTTPException(status_code=404, detail="User profile not found")
811
- except HTTPException:
812
- raise
813
- except Exception as profile_error:
814
- logger.error(f"Tenant resolution error for user {request_body.user_id}: {profile_error}")
815
- raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
816
-
817
- # Validate tenant consistency and authorization.
818
- profile_company_id = profile.get("company_id")
819
- if final_data.get("company_id"):
820
- # User provided company_id: verify it matches their profile.
821
- if profile_company_id and final_data["company_id"] != profile_company_id:
822
- logger.warning(f"Tenant mismatch: user {request_body.user_id} attempted {final_data['company_id']}, assigned to {profile_company_id}")
823
- raise HTTPException(status_code=403, detail="User not authorized for this tenant")
824
- elif profile_company_id:
825
- # Backfill company_id from profile.
826
- final_data["company_id"] = profile_company_id
827
- elif request_body.user_id:
828
- # User has no tenant assignment.
829
- raise HTTPException(status_code=400, detail="User has no tenant assignment")
830
-
831
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
832
  # Backfill company name if missing.
833
  if not final_data.get("company") and profile.get("company"):
834
  final_data["company"] = profile["company"]
@@ -854,28 +739,19 @@ async def save_ticket(request_body: TicketSaveRequest):
854
  "is_potential_duplicate": False,
855
  "similarity": 0.0,
856
  }
857
- user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
858
- logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
859
 
 
 
 
 
 
 
 
 
 
860
 
861
- # Semantic duplicate check BEFORE inserting the ticket
862
- # This allows us to warn the user before confirming
863
- duplicate_check_result = None
864
- try:
865
- dupe_text = (request_body.description or request_body.subject or "").strip()
866
- if dupe_text:
867
- duplicate_check_result = await semantic_dupe_service.check_duplicate(
868
- text=dupe_text,
869
- company_id=final_data.get("company_id"),
870
- )
871
- if duplicate_check_result["is_duplicate"]:
872
- logger.info(
873
- f"[DUPLICATE] Ticket flagged as potential duplicate of "
874
- f"{duplicate_check_result['duplicate_ticket_id']} "
875
- f"(similarity: {duplicate_check_result['similarity']})"
876
- )
877
- except Exception as e:
878
- logger.warning(f"[DUPLICATE] Semantic check error (non-fatal): {e}")
879
 
880
  # --- Sanitize payload to only include valid Supabase DB columns ---
881
  # Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
@@ -883,14 +759,13 @@ async def save_ticket(request_body: TicketSaveRequest):
883
  VALID_TICKET_COLUMNS = {
884
  "user_id", "subject", "description", "category", "subcategory",
885
  "priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
886
- "confidence", "image_url", "company", "company_id",
887
- "sla_breach_at", "sla_response_due_at", "sla_status", "escalation_level", "metadata",
888
  }
889
  # Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
890
  existing_metadata = final_data.get("metadata") or {}
891
  extra_keys = (
892
  "entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
893
- "is_potential_duplicate", "parent_ticket_id"
894
  )
895
  for extra_key in extra_keys:
896
  if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
@@ -907,29 +782,19 @@ async def save_ticket(request_body: TicketSaveRequest):
907
 
908
  ticket_id = res.data[0]["id"]
909
 
910
- # If duplicate detected, link parent ticket
911
- if duplicate_check_result and duplicate_check_result["is_duplicate"]:
912
- try:
913
- supabase.table("tickets").update({
914
- "is_potential_duplicate": True,
915
- "parent_ticket_id": duplicate_check_result["duplicate_ticket_id"],
916
- }).eq("id", ticket_id).execute()
917
- except Exception as e:
918
- logger.warning(f"[DUPLICATE] Failed to link parent ticket: {e}")
919
-
920
- # Index the new ticket's embedding for future duplicate checks
921
- embedding_indexed = False
922
- description_text = (request_body.description or "").strip()
923
- subject_text = (request_body.subject or "").strip()
924
- duplicate_text = description_text or subject_text
925
  if duplicate_text:
926
  try:
927
- # Both: old in-memory index (for backward compat) and new pgvector index
928
  duplicate_service.add_ticket(str(ticket_id), duplicate_text)
929
- asyncio.create_task(semantic_dupe_service.index_ticket(ticket_id, duplicate_text))
930
- embedding_indexed = True
931
  except Exception as index_error:
932
- logger.warning(f"[INDEX] Failed to index ticket {ticket_id}: {index_error}")
 
 
 
 
 
 
933
 
934
  # Add initial system diagnostic message
935
  msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
@@ -947,14 +812,12 @@ async def save_ticket(request_body: TicketSaveRequest):
947
  response = {
948
  "status": "success",
949
  "ticket_id": ticket_id,
950
- "duplicate_indexed": embedding_indexed,
 
 
951
  }
952
- if duplicate_check_result and duplicate_check_result["is_duplicate"]:
953
- response["duplicate_warning"] = True
954
- response["parent_ticket_id"] = duplicate_check_result["duplicate_ticket_id"]
955
- response["parent_subject"] = duplicate_check_result.get("parent_subject")
956
- response["similarity"] = duplicate_check_result["similarity"]
957
- response["candidates"] = duplicate_check_result.get("candidates", [])
958
  return response
959
 
960
  except Exception as e:
@@ -962,70 +825,17 @@ async def save_ticket(request_body: TicketSaveRequest):
962
  raise HTTPException(status_code=500, detail=str(e))
963
 
964
  @app.get("/tickets/{ticket_id}")
965
- async def get_ticket_by_id(
966
- request: Request,
967
- ticket_id: str,
968
- ):
969
  """Fetch single persistent ticket."""
970
  if not supabase:
971
  raise HTTPException(status_code=500, detail="Database connection not initialized")
972
-
973
- # Guard route overlap where '/tickets/search' may be matched here first.
974
- if ticket_id == "search":
975
- return await search_tickets(
976
- q=request.query_params.get("q", ""),
977
- company_id=request.query_params.get("company_id"),
978
- )
979
-
980
  res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
981
  if not res.data:
982
  raise HTTPException(status_code=404, detail="Ticket not found")
983
  return res.data
984
 
985
 
986
- @app.get("/tickets/{ticket_id}/audit_logs", response_model=list[AuditLogRecord])
987
- async def get_ticket_audit_logs(ticket_id: str, company_id: str):
988
- """Return a company-scoped chronological audit trail for a ticket."""
989
- if not supabase:
990
- raise HTTPException(status_code=500, detail="Database connection not initialized")
991
-
992
- try:
993
- service = AuditLogService(supabase)
994
- return service.get_ticket_audit_logs(ticket_id, company_id)
995
- except AuditLogAccessError as err:
996
- raise HTTPException(status_code=err.status_code, detail=err.detail)
997
-
998
-
999
- @app.get("/tickets/search")
1000
- async def search_tickets(q: str, company_id: str | None = None):
1001
- """Search tickets by query text, optionally scoped by company_id."""
1002
- if not supabase:
1003
- raise HTTPException(status_code=500, detail="Database connection not initialized")
1004
- query_text = (q or "").strip()
1005
- if not query_text:
1006
- raise HTTPException(status_code=400, detail="Query text is required")
1007
-
1008
- try:
1009
- rpc_res = supabase.rpc(
1010
- "search_tickets",
1011
- {"query_text": query_text, "company_id": company_id},
1012
- ).execute()
1013
- return rpc_res.data or []
1014
- except Exception:
1015
- # Fallback for environments without RPC function support.
1016
- fallback = supabase.table("tickets").select("*").order("created_at", desc=True).execute()
1017
- rows = fallback.data or []
1018
- lowered = query_text.lower()
1019
- filtered = [
1020
- row for row in rows
1021
- if lowered in str(row.get("subject", "")).lower()
1022
- or lowered in str(row.get("description", "")).lower()
1023
- ]
1024
- if company_id:
1025
- filtered = [row for row in filtered if row.get("company_id") == company_id]
1026
- return filtered
1027
-
1028
-
1029
  @app.post("/tickets", response_model=TicketRecord)
1030
  async def create_ticket(ticket: TicketRecord):
1031
  """Save a new ticket into the system."""
@@ -1064,6 +874,11 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
1064
  Main endpoint for analyzing a new ticket using the cascade of local AI models.
1065
  """
1066
  text = request_body.text
 
 
 
 
 
1067
 
1068
  # Grab client metadata
1069
  client_ip = request.client.host if request.client else "unknown"
@@ -1096,8 +911,6 @@ async def analyze_only(request_body: TicketRequest):
1096
  and duplicate check before committing to a ticket creation.
1097
  """
1098
  text = request_body.text
1099
- translation_ctx = detect_and_translate_ticket_text(text)
1100
- text = translation_ctx["text_for_analysis"]
1101
  print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
1102
  settings = get_system_settings(request_body.company)
1103
  confidence_threshold = settings["ai_confidence_threshold"]
@@ -1142,10 +955,6 @@ async def analyze_only(request_body: TicketRequest):
1142
  is_potential_duplicate=False,
1143
  parent_ticket_id=None,
1144
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
1145
- original_text=request_body.text,
1146
- source_language=translation_ctx["source_language"],
1147
- source_language_name=translation_ctx["source_language_name"],
1148
- was_translated=translation_ctx["was_translated"],
1149
  )
1150
 
1151
  # --- Context & Environment ---
@@ -1178,7 +987,36 @@ async def analyze_only(request_body: TicketRequest):
1178
  summary = text[:100] + ("…" if len(text) > 100 else "")
1179
 
1180
  # --- Classification ---
1181
- classification = classify_ticket_text(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1182
 
1183
  timeline["ai_analyzed"] = get_now_ist()
1184
  timeline["triaged"] = get_now_ist()
@@ -1192,10 +1030,21 @@ async def analyze_only(request_body: TicketRequest):
1192
  timeline["metadata_harvested"] = get_now_ist()
1193
 
1194
  # --- Duplicate detection ---
 
1195
  try:
1196
- dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
 
 
 
 
1197
  except Exception:
1198
- dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
 
 
 
 
 
 
1199
 
1200
  # --- RAG Knowledge Base Check ---
1201
  rag_match = None
@@ -1211,7 +1060,7 @@ async def analyze_only(request_body: TicketRequest):
1211
 
1212
  # --- Reasoning ---
1213
  decision_factors = []
1214
- if classification["confidence"] > request_body.confidence_threshold:
1215
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1216
  if entities:
1217
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
@@ -1221,6 +1070,14 @@ async def analyze_only(request_body: TicketRequest):
1221
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1222
 
1223
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
 
 
 
 
 
 
 
 
1224
  if classification["auto_resolve"]:
1225
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1226
 
@@ -1230,10 +1087,8 @@ async def analyze_only(request_body: TicketRequest):
1230
  if gemini_service and gemini_service._initialized:
1231
  summary = gemini_service.get_summary(text)
1232
 
1233
- # Convert priority to SLA breached timestamp (for preview)
1234
- hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
1235
- sla_hours = hours_map.get(classification["priority"], 72)
1236
- sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
1237
 
1238
  return TicketResponse(
1239
  ticket_id=str(uuid.uuid4()), # Temporary ID
@@ -1246,21 +1101,17 @@ async def analyze_only(request_body: TicketRequest):
1246
  entities=[EntityInfo(**e) for e in entities],
1247
  duplicate_ticket=DuplicateInfo(**dup_result),
1248
  confidence=classification["confidence"],
1249
- needs_review=classification["confidence"] < 0.20,
1250
  reasoning=reasoning,
1251
  decision_factors=decision_factors,
1252
  image_description=gemini_analysis["image_description"],
1253
  ocr_text=gemini_analysis["ocr_text"],
1254
- highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1255
  timeline=timeline,
1256
  env_metadata=env_metadata,
1257
  is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1258
  parent_ticket_id=dup_result.get("parent_ticket_id"),
1259
- sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z"),
1260
- original_text=translation_ctx["original_text"],
1261
- source_language=translation_ctx["source_language"],
1262
- source_language_name=translation_ctx["source_language_name"],
1263
- was_translated=translation_ctx["was_translated"],
1264
  )
1265
 
1266
  @app.post("/ai/analyze_stream")
@@ -1279,7 +1130,11 @@ async def analyze_stream(request_body: TicketRequest):
1279
  "model_version": "3.0.0-PRO",
1280
  "api_endpoint": "/ai/analyze_stream"
1281
  }
1282
- timeline = {"received": get_now_ist()}
 
 
 
 
1283
 
1284
  # 1. Reading
1285
  yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
@@ -1307,7 +1162,33 @@ async def analyze_stream(request_body: TicketRequest):
1307
  # 3. Classification
1308
  yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
1309
  await asyncio.sleep(0.2)
1310
- classification = classify_ticket_text(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1311
  timeline["ai_analyzed"] = get_now_ist()
1312
  timeline["triaged"] = get_now_ist()
1313
 
@@ -1315,9 +1196,20 @@ async def analyze_stream(request_body: TicketRequest):
1315
  yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
1316
  await asyncio.sleep(0.2)
1317
  try:
1318
- dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
 
 
 
 
 
1319
  except Exception:
1320
- dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
 
 
 
 
 
 
1321
 
1322
  # 5. RAG / Solutions
1323
  yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
@@ -1333,7 +1225,7 @@ async def analyze_stream(request_body: TicketRequest):
1333
  pass
1334
 
1335
  decision_factors = []
1336
- if classification["confidence"] > request_body.confidence_threshold:
1337
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1338
  if entities:
1339
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
@@ -1342,6 +1234,8 @@ async def analyze_stream(request_body: TicketRequest):
1342
  if rag_match:
1343
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1344
 
 
 
1345
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1346
  if classification["auto_resolve"]:
1347
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
@@ -1351,9 +1245,7 @@ async def analyze_stream(request_body: TicketRequest):
1351
  if gemini_service and gemini_service._initialized:
1352
  summary = gemini_service.get_summary(text)
1353
 
1354
- hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
1355
- sla_hours = hours_map.get(classification["priority"], 72)
1356
- sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
1357
 
1358
  ticket_response_dict = {
1359
  "ticket_id": str(uuid.uuid4()),
@@ -1366,15 +1258,17 @@ async def analyze_stream(request_body: TicketRequest):
1366
  "entities": [e for e in entities],
1367
  "duplicate_ticket": dup_result,
1368
  "confidence": classification["confidence"],
1369
- "needs_review": classification["confidence"] < 0.20,
1370
  "reasoning": reasoning,
1371
  "decision_factors": decision_factors,
1372
  "image_description": gemini_analysis["image_description"],
1373
  "ocr_text": gemini_analysis["ocr_text"],
1374
- "highlights": [e.get("text", "") for e in entities],
1375
  "timeline": timeline,
1376
  "env_metadata": env_metadata,
1377
- "sla_breach_at": sla_breach_dt.isoformat() + "Z"
 
 
1378
  }
1379
 
1380
  # 6. Final Result
@@ -1406,258 +1300,3 @@ async def analyze_ticket_v2(request: TicketRequest):
1406
  }
1407
  except Exception as e:
1408
  raise HTTPException(status_code=500, detail=str(e))
1409
-
1410
-
1411
- # ---------------------------------------------------------------------------
1412
- # SLA Engine Endpoints
1413
- # ---------------------------------------------------------------------------
1414
-
1415
- class SLAStatsResponse(BaseModel):
1416
- total: int = 0
1417
- active: int = 0
1418
- breached: int = 0
1419
- warning: int = 0
1420
- met: int = 0
1421
- breach_rate: float = 0.0
1422
- by_priority: dict = {}
1423
-
1424
-
1425
- @app.get("/sla/stats", response_model=SLAStatsResponse)
1426
- async def sla_stats():
1427
- """Get aggregated SLA dashboard statistics across all tickets."""
1428
- if not supabase:
1429
- raise HTTPException(status_code=503, detail="Database not connected")
1430
- stats = await sla_engine.get_dashboard_stats()
1431
- if "error" in stats:
1432
- raise HTTPException(status_code=500, detail=stats["error"])
1433
- return stats
1434
-
1435
-
1436
- class SLATicketInfo(BaseModel):
1437
- id: str
1438
- ticket_id: str | None = None
1439
- subject: str | None = None
1440
- summary: str | None = None
1441
- priority: str = "medium"
1442
- status: str | None = None
1443
- assigned_team: str | None = None
1444
- sla_status: str = "active"
1445
- escalation_level: int = 0
1446
- remaining_seconds: int = 0
1447
- created_at: str | None = None
1448
- sla_breach_at: str | None = None
1449
- sla_warning_at: str | None = None
1450
- last_escalated_at: str | None = None
1451
-
1452
-
1453
- @app.get("/sla/tickets")
1454
- async def sla_tickets(
1455
- status: str | None = None,
1456
- priority: str | None = None,
1457
- limit: int = 100,
1458
- offset: int = 0,
1459
- ):
1460
- """
1461
- List tickets with SLA status. Filter by sla_status and/or priority.
1462
- """
1463
- if not supabase:
1464
- raise HTTPException(status_code=503, detail="Database not connected")
1465
-
1466
- query = (
1467
- supabase.table("tickets")
1468
- .select("id, ticket_id, subject, summary, priority, status, assigned_team, sla_status, escalation_level, remaining_seconds, created_at, sla_breach_at, sla_warning_at, last_escalated_at")
1469
- .order("created_at", desc=True)
1470
- )
1471
-
1472
- if status and status != "all":
1473
- query = query.eq("sla_status", status)
1474
- if priority and priority != "all":
1475
- query = query.eq("priority", priority.capitalize())
1476
-
1477
- query = query.range(offset, offset + limit - 1)
1478
- res = query.execute()
1479
- return {"tickets": res.data or [], "total": len(res.data or [])}
1480
-
1481
-
1482
- class EscalationLogEntry(BaseModel):
1483
- id: str
1484
- ticket_id: str | None = None
1485
- ticket_subject: str = ""
1486
- priority: str = "medium"
1487
- sla_status: str = ""
1488
- escalation_level: int = 0
1489
- remaining_seconds: int = 0
1490
- assigned_team: str = ""
1491
- notification_channels: list = []
1492
- triggered_at: str | None = None
1493
- resolved_at: str | None = None
1494
- notes: str = ""
1495
-
1496
-
1497
- @app.get("/sla/escalations")
1498
- async def sla_escalations(limit: int = 50, offset: int = 0):
1499
- """Fetch escalation log history."""
1500
- if not supabase:
1501
- raise HTTPException(status_code=503, detail="Database not connected")
1502
-
1503
- try:
1504
- res = (
1505
- supabase.table("escalation_logs")
1506
- .select("*")
1507
- .order("triggered_at", desc=True)
1508
- .range(offset, offset + limit - 1)
1509
- .execute()
1510
- )
1511
- return {"escalations": res.data or [], "total": len(res.data or [])}
1512
- except Exception as e:
1513
- # Table might not exist yet
1514
- print(f"[SLA] Escalation logs query failed: {e}")
1515
- return {"escalations": [], "total": 0}
1516
-
1517
-
1518
- class SLAPolicyInfo(BaseModel):
1519
- id: str
1520
- priority: str
1521
- max_hours: int
1522
- warning_pct: float
1523
- auto_escalate: bool
1524
- l2_after_minutes: int
1525
- l3_after_minutes: int
1526
-
1527
-
1528
- @app.get("/sla/policies")
1529
- async def sla_policies():
1530
- """Get configured SLA policies."""
1531
- if not supabase:
1532
- # Return defaults from code
1533
- policies = []
1534
- for pri, cfg in sla_engine.SLA_POLICIES.items() if hasattr(sla_engine, 'SLA_POLICIES') else SLA_POLICIES.items():
1535
- policies.append({
1536
- "priority": pri,
1537
- "max_hours": cfg["max_hours"],
1538
- "warning_pct": cfg["warning_pct"],
1539
- "auto_escalate": cfg["auto_escalate_on_breach"],
1540
- "l2_after_minutes": cfg["l2_escalation_mins"],
1541
- "l3_after_minutes": cfg["l3_escalation_mins"],
1542
- })
1543
- return {"policies": policies}
1544
-
1545
- try:
1546
- res = supabase.table("sla_policies").select("*").execute()
1547
- return {"policies": res.data or []}
1548
- except Exception as e:
1549
- print(f"[SLA] Policies query failed: {e}")
1550
- return {"policies": []}
1551
-
1552
-
1553
- @app.post("/sla/check")
1554
- async def trigger_sla_check():
1555
- """Manually trigger an SLA evaluation cycle (admin)."""
1556
- if not supabase:
1557
- raise HTTPException(status_code=503, detail="Database not connected")
1558
-
1559
- asyncio.create_task(sla_engine.check_all_active_tickets())
1560
- return {"status": "triggered", "message": "SLA check cycle started in background"}
1561
-
1562
-
1563
- # ---------------------------------------------------------------------------
1564
- # Semantic Duplicate Detection Endpoints
1565
- # ---------------------------------------------------------------------------
1566
-
1567
- @app.post("/ai/check_duplicate")
1568
- async def check_duplicate_endpoint(
1569
- body: TicketRequest,
1570
- company_id: str | None = None,
1571
- ):
1572
- """
1573
- Check a ticket text for potential duplicates using semantic vector search.
1574
- Returns top candidates with similarity scores.
1575
- """
1576
- text = (body.text or "").strip()
1577
- if not text:
1578
- raise HTTPException(status_code=400, detail="No text provided")
1579
-
1580
- threshold = body.duplicate_sensitivity if hasattr(body, 'duplicate_sensitivity') else None
1581
- result = await semantic_dupe_service.check_duplicate(
1582
- text=text,
1583
- company_id=company_id or body.company,
1584
- threshold=threshold,
1585
- )
1586
- return result
1587
-
1588
-
1589
- @app.post("/ai/reindex_embeddings")
1590
- async def reindex_embeddings():
1591
- """Re-generate vector embeddings for all tickets."""
1592
- result = await semantic_dupe_service.reindex_all()
1593
- return result
1594
-
1595
-
1596
- @app.get("/system/settings")
1597
- async def get_system_settings_endpoint():
1598
- """Fetch all system settings."""
1599
- if not supabase:
1600
- raise HTTPException(status_code=503, detail="Database not connected")
1601
- try:
1602
- res = supabase.table("system_settings").select("*").execute()
1603
- settings = {}
1604
- for row in res.data or []:
1605
- settings[row["key"]] = row["value"]
1606
- return settings
1607
- except Exception as e:
1608
- logger.warning(f"[SETTINGS] Query failed: {e}")
1609
- return {}
1610
-
1611
-
1612
- @app.patch("/system/settings")
1613
- async def update_system_settings(body: dict):
1614
- """Update a specific system setting."""
1615
- if not supabase:
1616
- raise HTTPException(status_code=503, detail="Database not connected")
1617
- key = body.get("key")
1618
- value = body.get("value")
1619
- if not key or value is None:
1620
- raise HTTPException(status_code=400, detail="key and value required")
1621
- try:
1622
- supabase.table("system_settings").upsert({
1623
- "key": key,
1624
- "value": value,
1625
- "updated_at": datetime.datetime.utcnow().isoformat() + "Z",
1626
- }).execute()
1627
- return {"status": "updated", "key": key}
1628
- except Exception as e:
1629
- raise HTTPException(status_code=500, detail=str(e))
1630
-
1631
-
1632
- @app.get("/sla/tickets/{ticket_id}")
1633
- async def sla_ticket_detail(ticket_id: str):
1634
- """Get detailed SLA info for a specific ticket."""
1635
- if not supabase:
1636
- raise HTTPException(status_code=503, detail="Database not connected")
1637
-
1638
- # Fetch ticket
1639
- res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
1640
- if not res.data:
1641
- raise HTTPException(status_code=404, detail="Ticket not found")
1642
-
1643
- ticket = res.data
1644
- result = sla_engine.evaluate_ticket(ticket)
1645
-
1646
- # Fetch escalation history for this ticket
1647
- try:
1648
- esc_res = (
1649
- supabase.table("escalation_logs")
1650
- .select("*")
1651
- .eq("ticket_id", ticket_id)
1652
- .order("triggered_at", desc=True)
1653
- .execute()
1654
- )
1655
- escalations = esc_res.data or []
1656
- except Exception:
1657
- escalations = []
1658
-
1659
- return {
1660
- "ticket": ticket,
1661
- "sla_evaluation": result,
1662
- "escalations": escalations,
1663
- }
 
8
  import sys
9
  import uuid
10
  import json
 
11
  import datetime
12
  import traceback
13
  import warnings
 
24
  from slowapi.util import get_remote_address
25
  from slowapi.errors import RateLimitExceeded
26
  from fastapi.middleware.cors import CORSMiddleware
27
+ from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
28
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
29
  from fastapi.encoders import jsonable_encoder
30
  import asyncio
31
  from pathlib import Path
 
36
  env_path = Path(__file__).parent / '.env'
37
  load_dotenv(dotenv_path=env_path)
38
 
 
 
 
 
 
 
 
39
  # Initialize Supabase Client (Service Role for backend bypass)
40
  try:
41
  from supabase import create_client, Client
 
57
  from backend.services.classifier_service import ClassifierService
58
  from backend.services.classifier_v2 import classifier_v2
59
  from backend.services.classifier_v3 import classifier_v3 # V3 Power Model
 
 
60
  from backend.services.ner_service import NERService
61
  from backend.services.duplicate_service import DuplicateService
62
  from backend.services.rag_service import RagService
63
+ from backend.services.sla_service import (
64
+ calculate_sla_breach_at,
65
+ calculate_sla_response_at,
66
+ classify_sla_status,
67
+ load as load_sla_service,
68
+ run_sla_escalation_loop,
69
+ )
70
 
71
 
72
  # ---------------------------------------------------------------------------
 
115
  duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
116
  duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
117
  return duplicate_result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  class TicketRequest(BaseModel):
119
  text: str
120
  image_base64: str = ""
121
  image_text: str = "" # Keep for backward compatibility
122
  user_id: str | None = None
123
  company: str | None = None
124
+ company_id: str | None = None
125
  image_url: str | None = None
126
  confidence_threshold: float = 0.20
127
  duplicate_sensitivity: float = 0.85
 
141
  image_url: str | None = None
142
  company: str | None = None
143
  company_id: str | None = None
144
+ description_vector: list[float] | None = None
145
+ is_potential_duplicate: bool = False
146
+ parent_ticket_id: str | None = None
147
+ sla_response_due_at: str | None = None
148
  sla_breach_at: str
149
  sla_status: str | None = None
150
  escalation_level: int = 0
 
160
  class DuplicateInfo(BaseModel):
161
  is_duplicate: bool
162
  duplicate_ticket_id: str | None = None
163
+ parent_ticket_id: str | None = None
164
+ is_potential_duplicate: bool = False
165
  similarity: float = 0.0
166
 
167
 
 
183
  entities: list[EntityInfo]
184
  duplicate_ticket: DuplicateInfo
185
  confidence: float
186
+ is_potential_duplicate: bool = False
187
+ parent_ticket_id: str | None = None
188
  needs_review: bool = False
189
  reasoning: str = ""
190
  decision_factors: list[str] = []
 
194
  timeline: dict = {} # Map of step_name: timestamp
195
  env_metadata: dict = {} # IP, Hostname, Browser/OS
196
  sla_breach_at: str | None = None
 
 
 
 
197
  version: str = "2.1.0-Neural-Diagnostic"
198
 
199
 
 
221
  timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
222
 
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  # --- In-Memory Database (to be replaced with SQL later) ---
225
  TICKETS_DB: list[TicketRecord] = []
226
 
 
243
  ner_service = NERService()
244
  duplicate_service = DuplicateService()
245
  rag_service = RagService()
 
 
246
 
247
  try:
248
  from backend.services.gemini_service import GeminiService
 
256
  except ImportError:
257
  ocr_service = None
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
  # ---------------------------------------------------------------------------
261
  # Lifespan (startup / shutdown)
 
264
  async def lifespan(app: FastAPI):
265
  """Load all models at startup."""
266
  print("[Startup] Loading AI models ...")
 
 
 
 
267
  try:
268
  classifier_service.load()
269
  except FileNotFoundError as e:
 
280
  rag_service.load()
281
  except Exception as e:
282
  print(f"[WARNING] RAG service not loaded: {e}")
 
 
 
 
283
 
284
  if gemini_service:
285
  print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
286
  else:
287
  print("[Startup] Gemini Service: NOT LOADED (Import failed)")
288
 
289
+ print("[Startup] Classifier V2 Shadow: Ready.")
290
+ print("[Startup] Ready.")
291
+ # Strict health checks: fail loudly when core model assets are unavailable.
292
+ # Set ALLOW_DEGRADED_STARTUP=1 to permit degraded startup for local/dev convenience.
 
293
  try:
294
+ strict_mode = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") != "1"
295
+ except Exception:
296
+ strict_mode = True
 
 
297
 
298
+ classifier_loaded_flag = getattr(classifier_service, "_loaded", False)
299
+ ner_loaded_flag = getattr(ner_service, "_loaded", False)
 
 
 
300
 
301
+ if strict_mode and not classifier_loaded_flag:
302
+ raise RuntimeError("[Startup-FATAL] Classifier assets not loaded. Set ALLOW_DEGRADED_STARTUP=1 to bypass.")
303
+
304
+ sla_task = None
305
+ try:
306
+ if supabase and os.environ.get("SLA_ESCALATION_ENABLED", "true").lower() == "true":
307
+ notification_router = None
308
+ try:
309
+ from backend.services.notification_routing import load as load_notification_router
310
+ notification_router = load_notification_router()
311
+ except Exception as e:
312
+ print(f"[WARNING] Notification router not loaded for SLA service: {e}")
313
+ sla_service = load_sla_service(supabase, notification_router)
314
+ interval = int(os.environ.get("SLA_ESCALATION_INTERVAL_SECONDS", "300"))
315
+ sla_task = asyncio.create_task(run_sla_escalation_loop(sla_service, interval_seconds=interval))
316
+ print(f"[Startup] SLA escalation loop enabled ({interval}s interval).")
317
+
318
+ yield
319
+ finally:
320
+ if sla_task:
321
+ sla_task.cancel()
322
+ try:
323
+ await sla_task
324
+ except asyncio.CancelledError:
325
+ pass
326
+ print("[Shutdown] Cleaning up ...")
327
 
328
 
329
  # ---------------------------------------------------------------------------
 
354
  allow_headers=["*"],
355
  )
356
 
 
 
357
 
358
  # ---------------------------------------------------------------------------
359
  # Root & Health check
 
440
  """
441
 
442
 
443
+ @app.get("/metrics")
444
+ async def metrics():
445
+ """Prometheus scrape endpoint — exposes AI inference latency, request counts, and tokens."""
446
+ return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
447
+
448
+
449
  @app.get("/health", response_model=HealthResponse)
450
  async def health_check():
451
  return HealthResponse(
 
458
  @app.get("/ready", response_model=ReadinessResponse)
459
  async def readiness_check():
460
  require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
461
+ allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
462
+
463
  checks = {
464
  "api": True,
465
  "classifier_loaded": classifier_service._loaded,
466
  "ner_loaded": ner_service._loaded,
467
+ "duplicate_index_loaded": duplicate_service.is_available(),
468
+ "rag_loaded": rag_service.is_available(),
469
  }
470
  if require_supabase:
471
  checks["supabase_configured"] = supabase is not None
472
 
473
+ # In degraded mode, duplicate and RAG services are optional
474
+ if allow_degraded:
475
+ required_checks = {k: v for k, v in checks.items() if k not in ["duplicate_index_loaded", "rag_loaded"]}
476
+ all_required_pass = all(required_checks.values())
477
+
478
+ if all_required_pass:
479
+ return ReadinessResponse(status="ready", checks=checks)
480
+ else:
481
+ # Strict mode: all checks must pass
482
+ if all(checks.values()):
483
+ return ReadinessResponse(status="ready", checks=checks)
484
 
485
  return JSONResponse(
486
  status_code=503,
 
621
  res = query.execute()
622
  return res.data
623
 
624
+ @app.get("/tickets/search")
625
+ async def search_tickets(q: str | None = None, company_id: str | None = None, limit: int = 50, offset: int = 0):
626
+ """Search tickets using tenant-safe full-text search."""
627
+ if not supabase:
628
+ raise HTTPException(status_code=500, detail="Database connection not initialized")
629
+
630
+ if not q:
631
+ raise HTTPException(status_code=400, detail="Search query is required")
632
+ if not company_id:
633
+ raise HTTPException(status_code=400, detail="company_id is required for tenant-safe search")
634
+
635
+ try:
636
+ result = supabase.rpc(
637
+ "search_tickets",
638
+ {
639
+ "query_text": q,
640
+ "company_id": company_id,
641
+ "limit_rows": limit,
642
+ "offset_rows": offset,
643
+ },
644
+ ).execute()
645
+ return result.data or []
646
+ except Exception as e:
647
+ raise HTTPException(status_code=500, detail=f"Search failed: {e}")
648
+
649
  @app.post("/tickets/save")
650
  async def save_ticket(request_body: TicketSaveRequest):
651
  """
 
656
  raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
657
 
658
  logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  try:
660
+ final_data = request_body.dict()
661
+
662
+ # Resolve tenant linkage from user profile with authorization validation.
663
+ profile = {}
664
+ if request_body.user_id:
665
+ try:
666
+ profile_res = (
667
+ supabase.table("profiles")
668
+ .select("company_id, company")
669
+ .eq("id", request_body.user_id)
670
+ .single()
671
+ .execute()
672
+ )
673
+ profile = profile_res.data or {}
674
+ if not profile:
675
+ raise HTTPException(status_code=404, detail="User profile not found")
676
+
677
+ # SELF-HEALING: If company_id is null in database but company name exists, resolve it!
678
+ if not profile.get("company_id") and profile.get("company"):
679
+ try:
680
+ comp_name = profile.get("company").strip()
681
+ comp_res = (
682
+ supabase.table("companies")
683
+ .select("id")
684
+ .ilike("name", comp_name)
685
+ .execute()
686
+ )
687
+ if comp_res.data:
688
+ resolved_company_id = comp_res.data[0]["id"]
689
+ # Backfill the profile table in real-time
690
+ supabase.table("profiles").update({"company_id": resolved_company_id}).eq("id", request_body.user_id).execute()
691
+ profile["company_id"] = resolved_company_id
692
+ logger.info(f"[SELF-HEALING] Backfilled company_id={resolved_company_id} for user={request_body.user_id}")
693
+ except Exception as healing_err:
694
+ logger.warning(f"[SELF-HEALING WARNING] Failed to backfill company_id: {healing_err}")
695
+ except HTTPException:
696
+ raise
697
+ except Exception as profile_error:
698
+ user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
699
+ logger.error(f"Tenant resolution error for user {user_hash}: {profile_error}")
700
+ raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
701
+
702
+ # Validate tenant consistency and authorization.
703
+ profile_company_id = profile.get("company_id")
704
+ if final_data.get("company_id"):
705
+ # User provided company_id: verify it matches their profile.
706
+ if profile_company_id and final_data["company_id"] != profile_company_id:
707
+ user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
708
+ logger.warning(f"Tenant mismatch: user {user_hash} attempted {final_data['company_id']}, assigned to {profile_company_id}")
709
+ raise HTTPException(status_code=403, detail="User not authorized for this tenant")
710
+ elif profile_company_id:
711
+ # Backfill company_id from profile.
712
+ final_data["company_id"] = profile_company_id
713
+ elif request_body.user_id:
714
+ # User has no tenant assignment.
715
+ raise HTTPException(status_code=400, detail="User has no tenant assignment")
716
+
717
  # Backfill company name if missing.
718
  if not final_data.get("company") and profile.get("company"):
719
  final_data["company"] = profile["company"]
 
739
  "is_potential_duplicate": False,
740
  "similarity": 0.0,
741
  }
 
 
742
 
743
+ if duplicate_text:
744
+ duplicate_result = detect_semantic_duplicate(
745
+ duplicate_text,
746
+ company_id=final_data.get("company_id"),
747
+ threshold=duplicate_threshold,
748
+ )
749
+ final_data["description_vector"] = duplicate_service.generate_embedding(duplicate_text)
750
+ else:
751
+ final_data["description_vector"] = None
752
 
753
+ final_data["is_potential_duplicate"] = duplicate_result.get("is_potential_duplicate", False)
754
+ final_data["parent_ticket_id"] = duplicate_result.get("parent_ticket_id")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
 
756
  # --- Sanitize payload to only include valid Supabase DB columns ---
757
  # Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
 
759
  VALID_TICKET_COLUMNS = {
760
  "user_id", "subject", "description", "category", "subcategory",
761
  "priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
762
+ "confidence", "image_url", "company", "company_id", "sla_breach_at", "metadata",
 
763
  }
764
  # Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
765
  existing_metadata = final_data.get("metadata") or {}
766
  extra_keys = (
767
  "entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
768
+ "is_potential_duplicate", "parent_ticket_id", "sla_response_due_at", "sla_status", "escalation_level"
769
  )
770
  for extra_key in extra_keys:
771
  if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
 
782
 
783
  ticket_id = res.data[0]["id"]
784
 
785
+ duplicate_indexed = True
786
+ duplicate_index_warning = None
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  if duplicate_text:
788
  try:
 
789
  duplicate_service.add_ticket(str(ticket_id), duplicate_text)
 
 
790
  except Exception as index_error:
791
+ duplicate_indexed = False
792
+ duplicate_index_warning = "Duplicate index update failed."
793
+ print(f"[WARNING] {duplicate_index_warning} ticket_id={ticket_id} error={index_error}")
794
+ else:
795
+ duplicate_indexed = False
796
+ duplicate_index_warning = "Duplicate index update skipped: no description or subject text was provided."
797
+ print(f"[WARNING] {duplicate_index_warning}")
798
 
799
  # Add initial system diagnostic message
800
  msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
 
812
  response = {
813
  "status": "success",
814
  "ticket_id": ticket_id,
815
+ "duplicate_indexed": duplicate_indexed,
816
+ "is_potential_duplicate": final_data["is_potential_duplicate"],
817
+ "parent_ticket_id": final_data["parent_ticket_id"],
818
  }
819
+ if duplicate_index_warning:
820
+ response["duplicate_index_warning"] = duplicate_index_warning
 
 
 
 
821
  return response
822
 
823
  except Exception as e:
 
825
  raise HTTPException(status_code=500, detail=str(e))
826
 
827
  @app.get("/tickets/{ticket_id}")
828
+ async def get_ticket_by_id(ticket_id: str):
 
 
 
829
  """Fetch single persistent ticket."""
830
  if not supabase:
831
  raise HTTPException(status_code=500, detail="Database connection not initialized")
832
+
 
 
 
 
 
 
 
833
  res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
834
  if not res.data:
835
  raise HTTPException(status_code=404, detail="Ticket not found")
836
  return res.data
837
 
838
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
839
  @app.post("/tickets", response_model=TicketRecord)
840
  async def create_ticket(ticket: TicketRecord):
841
  """Save a new ticket into the system."""
 
874
  Main endpoint for analyzing a new ticket using the cascade of local AI models.
875
  """
876
  text = request_body.text
877
+
878
+ settings = get_system_settings(request_body.company_id)
879
+ confidence_threshold = settings["ai_confidence_threshold"]
880
+ duplicate_sensitivity = settings["duplicate_sensitivity"]
881
+ enable_auto_resolve = settings["enable_auto_resolve"]
882
 
883
  # Grab client metadata
884
  client_ip = request.client.host if request.client else "unknown"
 
911
  and duplicate check before committing to a ticket creation.
912
  """
913
  text = request_body.text
 
 
914
  print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
915
  settings = get_system_settings(request_body.company)
916
  confidence_threshold = settings["ai_confidence_threshold"]
 
955
  is_potential_duplicate=False,
956
  parent_ticket_id=None,
957
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
 
 
 
 
958
  )
959
 
960
  # --- Context & Environment ---
 
987
  summary = text[:100] + ("…" if len(text) > 100 else "")
988
 
989
  # --- Classification ---
990
+ try:
991
+ classification_v3_res = classifier_v3.predict(text)
992
+ if "error" in classification_v3_res:
993
+ # Fallback to V1
994
+ classification = classifier_service.predict(text)
995
+ else:
996
+ # Parse V3 output
997
+ cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
998
+ sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
999
+ pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
1000
+ conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
1001
+
1002
+ from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
1003
+ assigned_team = TEAM_MAP.get(cat, "General Support")
1004
+ auto_resolve = sub in AUTO_RESOLVE_SUBS
1005
+
1006
+ classification = {
1007
+ "category": cat,
1008
+ "subcategory": sub,
1009
+ "priority": pri,
1010
+ "auto_resolve": auto_resolve,
1011
+ "assigned_team": assigned_team,
1012
+ "confidence": float(conf)
1013
+ }
1014
+ except Exception as e:
1015
+ traceback.print_exc()
1016
+ classification = {
1017
+ "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
1018
+ "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
1019
+ }
1020
 
1021
  timeline["ai_analyzed"] = get_now_ist()
1022
  timeline["triaged"] = get_now_ist()
 
1030
  timeline["metadata_harvested"] = get_now_ist()
1031
 
1032
  # --- Duplicate detection ---
1033
+ duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
1034
  try:
1035
+ dup_result = detect_semantic_duplicate(
1036
+ text,
1037
+ company_id=request_body.company_id,
1038
+ threshold=duplicate_threshold,
1039
+ )
1040
  except Exception:
1041
+ dup_result = {
1042
+ "is_duplicate": False,
1043
+ "duplicate_ticket_id": None,
1044
+ "parent_ticket_id": None,
1045
+ "is_potential_duplicate": False,
1046
+ "similarity": 0.0,
1047
+ }
1048
 
1049
  # --- RAG Knowledge Base Check ---
1050
  rag_match = None
 
1060
 
1061
  # --- Reasoning ---
1062
  decision_factors = []
1063
+ if classification["confidence"] > confidence_threshold:
1064
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1065
  if entities:
1066
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
 
1070
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1071
 
1072
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1073
+ if (
1074
+ enable_auto_resolve
1075
+ and classification["confidence"] >= confidence_threshold
1076
+ and classification["auto_resolve"]
1077
+ ):
1078
+ classification["auto_resolve"] = True
1079
+ else:
1080
+ classification["auto_resolve"] = False
1081
  if classification["auto_resolve"]:
1082
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1083
 
 
1087
  if gemini_service and gemini_service._initialized:
1088
  summary = gemini_service.get_summary(text)
1089
 
1090
+ # Convert priority to the SLA resolution target timestamp for preview.
1091
+ sla_breach_dt = calculate_sla_breach_at(classification["priority"])
 
 
1092
 
1093
  return TicketResponse(
1094
  ticket_id=str(uuid.uuid4()), # Temporary ID
 
1101
  entities=[EntityInfo(**e) for e in entities],
1102
  duplicate_ticket=DuplicateInfo(**dup_result),
1103
  confidence=classification["confidence"],
1104
+ needs_review=classification["confidence"] < confidence_threshold,
1105
  reasoning=reasoning,
1106
  decision_factors=decision_factors,
1107
  image_description=gemini_analysis["image_description"],
1108
  ocr_text=gemini_analysis["ocr_text"],
1109
+ highlights=entities, # Use entities as highlights for now
1110
  timeline=timeline,
1111
  env_metadata=env_metadata,
1112
  is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1113
  parent_ticket_id=dup_result.get("parent_ticket_id"),
1114
+ sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
 
 
 
 
1115
  )
1116
 
1117
  @app.post("/ai/analyze_stream")
 
1130
  "model_version": "3.0.0-PRO",
1131
  "api_endpoint": "/ai/analyze_stream"
1132
  }
1133
+ timeline = {"received": get_now_ist()}
1134
+ settings = get_system_settings(request_body.company_id)
1135
+ confidence_threshold = settings["ai_confidence_threshold"]
1136
+ duplicate_sensitivity = settings["duplicate_sensitivity"]
1137
+ enable_auto_resolve = settings["enable_auto_resolve"]
1138
 
1139
  # 1. Reading
1140
  yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
 
1162
  # 3. Classification
1163
  yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
1164
  await asyncio.sleep(0.2)
1165
+ try:
1166
+ classification_v3_res = classifier_v3.predict(text)
1167
+ if "error" in classification_v3_res:
1168
+ classification = classifier_service.predict(text)
1169
+ else:
1170
+ cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
1171
+ sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
1172
+ pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
1173
+ conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
1174
+
1175
+ from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
1176
+ assigned_team = TEAM_MAP.get(cat, "General Support")
1177
+ auto_resolve = sub in AUTO_RESOLVE_SUBS
1178
+
1179
+ classification = {
1180
+ "category": cat,
1181
+ "subcategory": sub,
1182
+ "priority": pri,
1183
+ "auto_resolve": auto_resolve,
1184
+ "assigned_team": assigned_team,
1185
+ "confidence": float(conf)
1186
+ }
1187
+ except Exception as e:
1188
+ classification = {
1189
+ "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
1190
+ "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
1191
+ }
1192
  timeline["ai_analyzed"] = get_now_ist()
1193
  timeline["triaged"] = get_now_ist()
1194
 
 
1196
  yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
1197
  await asyncio.sleep(0.2)
1198
  try:
1199
+ duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
1200
+ dup_result = detect_semantic_duplicate(
1201
+ text,
1202
+ company_id=request_body.company_id,
1203
+ threshold=duplicate_threshold,
1204
+ )
1205
  except Exception:
1206
+ dup_result = {
1207
+ "is_duplicate": False,
1208
+ "duplicate_ticket_id": None,
1209
+ "parent_ticket_id": None,
1210
+ "is_potential_duplicate": False,
1211
+ "similarity": 0.0,
1212
+ }
1213
 
1214
  # 5. RAG / Solutions
1215
  yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
 
1225
  pass
1226
 
1227
  decision_factors = []
1228
+ if classification["confidence"] > confidence_threshold:
1229
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1230
  if entities:
1231
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
 
1234
  if rag_match:
1235
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1236
 
1237
+ if not enable_auto_resolve:
1238
+ classification["auto_resolve"] = False
1239
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1240
  if classification["auto_resolve"]:
1241
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
 
1245
  if gemini_service and gemini_service._initialized:
1246
  summary = gemini_service.get_summary(text)
1247
 
1248
+ sla_breach_dt = calculate_sla_breach_at(classification["priority"])
 
 
1249
 
1250
  ticket_response_dict = {
1251
  "ticket_id": str(uuid.uuid4()),
 
1258
  "entities": [e for e in entities],
1259
  "duplicate_ticket": dup_result,
1260
  "confidence": classification["confidence"],
1261
+ "needs_review": classification["confidence"] < confidence_threshold,
1262
  "reasoning": reasoning,
1263
  "decision_factors": decision_factors,
1264
  "image_description": gemini_analysis["image_description"],
1265
  "ocr_text": gemini_analysis["ocr_text"],
1266
+ "highlights": entities,
1267
  "timeline": timeline,
1268
  "env_metadata": env_metadata,
1269
+ "is_potential_duplicate": dup_result.get("is_potential_duplicate", False),
1270
+ "parent_ticket_id": dup_result.get("parent_ticket_id"),
1271
+ "sla_breach_at": sla_breach_dt.isoformat().replace("+00:00", "Z")
1272
  }
1273
 
1274
  # 6. Final Result
 
1300
  }
1301
  except Exception as e:
1302
  raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/requirements.txt CHANGED
@@ -4,8 +4,6 @@ datasets>=2.14.0
4
  pandas>=2.0.0
5
  scikit-learn>=1.3.0
6
  sentence-transformers>=2.2.0
7
- onnxruntime>=1.17.0
8
- tokenizers>=0.15.0
9
  fastapi>=0.104.0
10
  uvicorn>=0.24.0
11
  openpyxl>=3.1.0
@@ -17,8 +15,4 @@ easyocr
17
  slowapi>=0.1.9
18
  supabase==2.22.4
19
  storage3==2.22.4
20
- redis>=5.0.0
21
- pytest
22
- pytest-asyncio
23
- httpx
24
-
 
4
  pandas>=2.0.0
5
  scikit-learn>=1.3.0
6
  sentence-transformers>=2.2.0
 
 
7
  fastapi>=0.104.0
8
  uvicorn>=0.24.0
9
  openpyxl>=3.1.0
 
15
  slowapi>=0.1.9
16
  supabase==2.22.4
17
  storage3==2.22.4
18
+ prometheus-client>=0.19.0
 
 
 
 
backend/services/classifier_service.py CHANGED
@@ -6,20 +6,23 @@ Priority and other fields are derived from the category mapping.
6
 
7
  import os
8
  import json
 
 
 
 
 
9
  try:
10
- import torch
11
- import torch.nn.functional as F
12
- from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification
13
- _HAS_TORCH = True
14
- except Exception: # pragma: no cover - optional CI/runtime dependency
15
- torch = None
16
- F = None
17
- DistilBertTokenizerFast = None
18
- DistilBertForSequenceClassification = None
19
- _HAS_TORCH = False
20
 
21
  SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "classifier")
22
- DEVICE = torch.device("cuda" if torch and torch.cuda.is_available() else "cpu") if _HAS_TORCH else None
23
  MAX_LEN = 128
24
 
25
  # Priority mapping based on sub-category severity
@@ -65,11 +68,6 @@ class ClassifierService:
65
  if self._loaded:
66
  return
67
 
68
- if not _HAS_TORCH:
69
- # Degraded environment: ML runtime not available. Delay failure until predict is called.
70
- print("[INFO] ML runtime not available; classifier will remain unloaded until dependencies are installed.")
71
- return
72
-
73
  abs_dir = os.path.abspath(SAVE_DIR)
74
 
75
  if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
@@ -111,11 +109,21 @@ class ClassifierService:
111
  input_ids = encoding["input_ids"].to(DEVICE)
112
  attention_mask = encoding["attention_mask"].to(DEVICE)
113
 
114
- with torch.no_grad():
115
- outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
116
- logits = outputs.logits
117
- probs = F.softmax(logits, dim=1)
118
- confidence, pred_idx = torch.max(probs, dim=1)
 
 
 
 
 
 
 
 
 
 
119
 
120
  pred_idx = pred_idx.item()
121
  confidence = round(confidence.item(), 4)
 
6
 
7
  import os
8
  import json
9
+ import time
10
+ import torch
11
+ import torch.nn.functional as F
12
+ from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification
13
+
14
  try:
15
+ from backend.services.metrics_service import (
16
+ CLASSIFIER_LATENCY,
17
+ CLASSIFIER_REQUESTS,
18
+ CLASSIFIER_TOKENS,
19
+ )
20
+ _METRICS_ENABLED = True
21
+ except Exception:
22
+ _METRICS_ENABLED = False
 
 
23
 
24
  SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "classifier")
25
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
26
  MAX_LEN = 128
27
 
28
  # Priority mapping based on sub-category severity
 
68
  if self._loaded:
69
  return
70
 
 
 
 
 
 
71
  abs_dir = os.path.abspath(SAVE_DIR)
72
 
73
  if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
 
109
  input_ids = encoding["input_ids"].to(DEVICE)
110
  attention_mask = encoding["attention_mask"].to(DEVICE)
111
 
112
+ _t0 = time.perf_counter()
113
+ try:
114
+ with torch.no_grad():
115
+ outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
116
+ logits = outputs.logits
117
+ probs = F.softmax(logits, dim=1)
118
+ confidence, pred_idx = torch.max(probs, dim=1)
119
+ except Exception:
120
+ if _METRICS_ENABLED:
121
+ CLASSIFIER_REQUESTS.labels(model="distilbert", status="error").inc()
122
+ raise
123
+ if _METRICS_ENABLED:
124
+ CLASSIFIER_LATENCY.labels(model="distilbert").observe(time.perf_counter() - _t0)
125
+ CLASSIFIER_REQUESTS.labels(model="distilbert", status="ok").inc()
126
+ CLASSIFIER_TOKENS.labels(model="distilbert").inc(int(attention_mask.sum().item()))
127
 
128
  pred_idx = pred_idx.item()
129
  confidence = round(confidence.item(), 4)
backend/services/classifier_v2.py CHANGED
@@ -1,109 +1,89 @@
1
  import os
 
 
2
  import pickle
3
  import json
4
- try:
5
- import torch
6
- import torch.nn as nn
7
- from transformers import DistilBertTokenizerFast, DistilBertModel
8
- _HAS_TORCH = True
9
- except Exception: # pragma: no cover - optional runtime dependency
10
- torch = None
11
- nn = None
12
- DistilBertTokenizerFast = None
13
- DistilBertModel = None
14
- _HAS_TORCH = False
15
 
16
  # Paths
17
  BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
  MODEL_DIR = os.path.join(BASE_DIR, "models", "classifier-v2")
19
 
20
  # We must use the exact same class definition as trainer_v2
21
- if _HAS_TORCH:
22
- class MultiOutputClassifierV2(nn.Module):
23
- def __init__(self, num_labels_per_output: dict):
24
- super().__init__()
25
- self.bert = DistilBertModel.from_pretrained("distilbert-base-uncased")
26
- hidden = self.bert.config.hidden_size
27
- self.dropout = nn.Dropout(0.2)
28
- self.heads = nn.ModuleDict()
29
- for name, n_labels in num_labels_per_output.items():
30
- self.heads[name] = nn.Linear(hidden, n_labels)
31
 
32
- def forward(self, input_ids, attention_mask):
33
- outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
34
- cls_output = outputs.last_hidden_state[:, 0]
35
- cls_output = self.dropout(cls_output)
36
- logits = {name: head(cls_output) for name, head in self.heads.items()}
37
- return logits
38
 
39
- class ClassifierServiceV2:
40
- def __init__(self):
41
- self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
42
-
43
- # 1. Load Config
44
- config_path = os.path.join(MODEL_DIR, "model_config.json")
45
- if not os.path.exists(config_path):
46
- self.model = None
47
- print(f"[WARN] V2 Model config not found at {config_path}")
48
- return
49
 
50
- with open(config_path, "r") as f:
51
- self.num_labels = json.load(f)
52
 
53
- # 2. Load Encoders
54
- with open(os.path.join(MODEL_DIR, "label_encoders.pkl"), "rb") as f:
55
- self.label_encoders = pickle.load(f)
56
 
57
- # 3. Load Model
58
- self.model = MultiOutputClassifierV2(self.num_labels).to(self.device)
59
- model_path = os.path.join(MODEL_DIR, "model.pt")
60
- self.model.load_state_dict(torch.load(model_path, map_location=self.device))
61
- self.model.eval()
62
 
63
- # 4. Load Tokenizer
64
- self.tokenizer = DistilBertTokenizerFast.from_pretrained(MODEL_DIR)
65
- print("[SUCCESS] Classifier Service V2 (Shadow) Loaded Successfully.")
66
 
67
- def predict(self, text: str):
68
- if self.model is None:
69
- return {"error": "V2 Model not initialized"}
70
 
71
- inputs = self.tokenizer(
72
- text,
73
- return_tensors="pt",
74
- truncation=True,
75
- padding=True,
76
- max_length=256 # V2 uses 256
77
- ).to(self.device)
78
 
79
- with torch.no_grad():
80
- logits = self.model(inputs["input_ids"], inputs["attention_mask"])
81
-
82
- results = {}
83
- for col, le in self.label_encoders.items():
84
- probs = torch.softmax(logits[col], dim=1)
85
- conf, pred_idx = torch.max(probs, dim=1)
86
- results[col] = {
87
- "prediction": le.inverse_transform([pred_idx.item()])[0],
88
- "confidence": float(conf.item())
89
- }
90
 
91
- # Map V2 'Priority' (capitalized) to generic response
92
- if "Priority" in results:
93
- results["priority"] = results.pop("Priority")
94
-
95
- return results
96
-
97
- # Singleton instance
98
- classifier_v2 = ClassifierServiceV2()
99
- else:
100
- class ClassifierServiceV2:
101
- def __init__(self):
102
- self.model = None
103
- self.tokenizer = None
104
- self.label_encoders = {}
105
 
106
- def predict(self, text: str):
107
- return {"error": "V2 model not available in this environment"}
108
 
109
- classifier_v2 = ClassifierServiceV2()
 
 
1
  import os
2
+ import torch
3
+ import torch.nn as nn
4
  import pickle
5
  import json
6
+ from transformers import DistilBertTokenizerFast, DistilBertModel
 
 
 
 
 
 
 
 
 
 
7
 
8
  # Paths
9
  BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10
  MODEL_DIR = os.path.join(BASE_DIR, "models", "classifier-v2")
11
 
12
  # We must use the exact same class definition as trainer_v2
13
+ class MultiOutputClassifierV2(nn.Module):
14
+ def __init__(self, num_labels_per_output: dict):
15
+ super().__init__()
16
+ self.bert = DistilBertModel.from_pretrained("distilbert-base-uncased")
17
+ hidden = self.bert.config.hidden_size
18
+ self.dropout = nn.Dropout(0.2)
19
+ self.heads = nn.ModuleDict()
20
+ for name, n_labels in num_labels_per_output.items():
21
+ self.heads[name] = nn.Linear(hidden, n_labels)
 
22
 
23
+ def forward(self, input_ids, attention_mask):
24
+ outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
25
+ cls_output = outputs.last_hidden_state[:, 0]
26
+ cls_output = self.dropout(cls_output)
27
+ logits = {name: head(cls_output) for name, head in self.heads.items()}
28
+ return logits
29
 
30
+ class ClassifierServiceV2:
31
+ def __init__(self):
32
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
33
+
34
+ # 1. Load Config
35
+ config_path = os.path.join(MODEL_DIR, "model_config.json")
36
+ if not os.path.exists(config_path):
37
+ self.model = None
38
+ print(f"[WARN] V2 Model config not found at {config_path}")
39
+ return
40
 
41
+ with open(config_path, "r") as f:
42
+ self.num_labels = json.load(f)
43
 
44
+ # 2. Load Encoders
45
+ with open(os.path.join(MODEL_DIR, "label_encoders.pkl"), "rb") as f:
46
+ self.label_encoders = pickle.load(f)
47
 
48
+ # 3. Load Model
49
+ self.model = MultiOutputClassifierV2(self.num_labels).to(self.device)
50
+ model_path = os.path.join(MODEL_DIR, "model.pt")
51
+ self.model.load_state_dict(torch.load(model_path, map_location=self.device))
52
+ self.model.eval()
53
 
54
+ # 4. Load Tokenizer
55
+ self.tokenizer = DistilBertTokenizerFast.from_pretrained(MODEL_DIR)
56
+ print("[SUCCESS] Classifier Service V2 (Shadow) Loaded Successfully.")
57
 
58
+ def predict(self, text: str):
59
+ if self.model is None:
60
+ return {"error": "V2 Model not initialized"}
61
 
62
+ inputs = self.tokenizer(
63
+ text,
64
+ return_tensors="pt",
65
+ truncation=True,
66
+ padding=True,
67
+ max_length=256 # V2 uses 256
68
+ ).to(self.device)
69
 
70
+ with torch.no_grad():
71
+ logits = self.model(inputs["input_ids"], inputs["attention_mask"])
 
 
 
 
 
 
 
 
 
72
 
73
+ results = {}
74
+ for col, le in self.label_encoders.items():
75
+ probs = torch.softmax(logits[col], dim=1)
76
+ conf, pred_idx = torch.max(probs, dim=1)
77
+ results[col] = {
78
+ "prediction": le.inverse_transform([pred_idx.item()])[0],
79
+ "confidence": float(conf.item())
80
+ }
81
+
82
+ # Map V2 'Priority' (capitalized) to generic response
83
+ if "Priority" in results:
84
+ results["priority"] = results.pop("Priority")
 
 
85
 
86
+ return results
 
87
 
88
+ # Singleton instance
89
+ classifier_v2 = ClassifierServiceV2()
backend/services/classifier_v3.py CHANGED
@@ -1,95 +1,75 @@
1
  import os
 
 
2
  import pickle
3
  import json
4
- try:
5
- import torch
6
- import torch.nn as nn
7
- from transformers import BertTokenizerFast, BertModel
8
- _HAS_TORCH = True
9
- except Exception: # pragma: no cover - optional runtime dependency
10
- torch = None
11
- nn = None
12
- BertTokenizerFast = None
13
- BertModel = None
14
- _HAS_TORCH = False
15
 
16
  # Paths
17
  BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
  MODEL_DIR = os.path.join(BASE_DIR, "models", "classifier-v3")
19
 
20
- if _HAS_TORCH:
21
- class MultiOutputClassifierV3(nn.Module):
22
- def __init__(self, num_labels_per_output: dict):
23
- super().__init__()
24
- self.bert = BertModel.from_pretrained("bert-base-uncased")
25
- hidden = self.bert.config.hidden_size
26
- self.dropout = nn.Dropout(0.3)
27
- self.heads = nn.ModuleDict()
28
- for name, n_labels in num_labels_per_output.items():
29
- self.heads[name] = nn.Sequential(
30
- nn.Linear(hidden, 256),
31
- nn.ReLU(),
32
- nn.Dropout(0.1),
33
- nn.Linear(256, n_labels)
34
- )
35
 
36
- def forward(self, input_ids, attention_mask):
37
- outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
38
- pooled_output = outputs.pooler_output
39
- pooled_output = self.dropout(pooled_output)
40
- logits = {name: head(pooled_output) for name, head in self.heads.items()}
41
- return logits
42
 
43
- class ClassifierServiceV3:
44
- def __init__(self):
45
- self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
46
- self.model = None
47
-
48
- config_path = os.path.join(MODEL_DIR, "model_config.json")
49
- if not os.path.exists(config_path):
50
- print(f"[V3 Service] Model not found yet at {MODEL_DIR}")
51
- return
52
 
53
- with open(config_path, "r") as f:
54
- self.num_labels = json.load(f)
55
 
56
- with open(os.path.join(MODEL_DIR, "label_encoders.pkl"), "rb") as f:
57
- self.label_encoders = pickle.load(f)
58
 
59
- self.model = MultiOutputClassifierV3(self.num_labels).to(self.device)
60
- self.model.load_state_dict(torch.load(os.path.join(MODEL_DIR, "model.pt"), map_location=self.device))
61
- self.model.eval()
62
 
63
- self.tokenizer = BertTokenizerFast.from_pretrained(MODEL_DIR)
64
- print("[INFO] Classifier Service V3 (Power Model) Loaded.")
65
 
66
- def predict(self, text: str):
67
- if self.model is None: return {"error": "V3 Model not loaded"}
68
- inputs = self.tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=256).to(self.device)
69
- with torch.no_grad():
70
- logits = self.model(inputs["input_ids"], inputs["attention_mask"])
71
-
72
- results = {}
73
- for col, le in self.label_encoders.items():
74
- probs = torch.softmax(logits[col], dim=1)
75
- conf, pred_idx = torch.max(probs, dim=1)
76
- results[col] = {
77
- "prediction": le.inverse_transform([pred_idx.item()])[0],
78
- "confidence": float(conf.item())
79
- }
80
 
81
- if "Priority" in results: results["priority"] = results.pop("Priority")
82
- return results
83
-
84
- classifier_v3 = ClassifierServiceV3()
85
- else:
86
- class ClassifierServiceV3:
87
- def __init__(self):
88
- self.model = None
89
- self.tokenizer = None
90
- self.label_encoders = {}
91
-
92
- def predict(self, text: str):
93
- return {"error": "V3 model not available in this environment"}
94
 
95
- classifier_v3 = ClassifierServiceV3()
 
1
  import os
2
+ import torch
3
+ import torch.nn as nn
4
  import pickle
5
  import json
6
+ from transformers import BertTokenizerFast, BertModel
 
 
 
 
 
 
 
 
 
 
7
 
8
  # Paths
9
  BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10
  MODEL_DIR = os.path.join(BASE_DIR, "models", "classifier-v3")
11
 
12
+ class MultiOutputClassifierV3(nn.Module):
13
+ def __init__(self, num_labels_per_output: dict):
14
+ super().__init__()
15
+ self.bert = BertModel.from_pretrained("bert-base-uncased")
16
+ hidden = self.bert.config.hidden_size
17
+ self.dropout = nn.Dropout(0.3)
18
+ self.heads = nn.ModuleDict()
19
+ for name, n_labels in num_labels_per_output.items():
20
+ self.heads[name] = nn.Sequential(
21
+ nn.Linear(hidden, 256),
22
+ nn.ReLU(),
23
+ nn.Dropout(0.1),
24
+ nn.Linear(256, n_labels)
25
+ )
 
26
 
27
+ def forward(self, input_ids, attention_mask):
28
+ outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
29
+ pooled_output = outputs.pooler_output
30
+ pooled_output = self.dropout(pooled_output)
31
+ logits = {name: head(pooled_output) for name, head in self.heads.items()}
32
+ return logits
33
 
34
+ class ClassifierServiceV3:
35
+ def __init__(self):
36
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
37
+ self.model = None
38
+
39
+ config_path = os.path.join(MODEL_DIR, "model_config.json")
40
+ if not os.path.exists(config_path):
41
+ print(f"[V3 Service] Model not found yet at {MODEL_DIR}")
42
+ return
43
 
44
+ with open(config_path, "r") as f:
45
+ self.num_labels = json.load(f)
46
 
47
+ with open(os.path.join(MODEL_DIR, "label_encoders.pkl"), "rb") as f:
48
+ self.label_encoders = pickle.load(f)
49
 
50
+ self.model = MultiOutputClassifierV3(self.num_labels).to(self.device)
51
+ self.model.load_state_dict(torch.load(os.path.join(MODEL_DIR, "model.pt"), map_location=self.device))
52
+ self.model.eval()
53
 
54
+ self.tokenizer = BertTokenizerFast.from_pretrained(MODEL_DIR)
55
+ print("[INFO] Classifier Service V3 (Power Model) Loaded.")
56
 
57
+ def predict(self, text: str):
58
+ if self.model is None: return {"error": "V3 Model not loaded"}
59
+ inputs = self.tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=256).to(self.device)
60
+ with torch.no_grad():
61
+ logits = self.model(inputs["input_ids"], inputs["attention_mask"])
 
 
 
 
 
 
 
 
 
62
 
63
+ results = {}
64
+ for col, le in self.label_encoders.items():
65
+ probs = torch.softmax(logits[col], dim=1)
66
+ conf, pred_idx = torch.max(probs, dim=1)
67
+ results[col] = {
68
+ "prediction": le.inverse_transform([pred_idx.item()])[0],
69
+ "confidence": float(conf.item())
70
+ }
71
+
72
+ if "Priority" in results: results["priority"] = results.pop("Priority")
73
+ return results
 
 
74
 
75
+ classifier_v3 = ClassifierServiceV3()
backend/services/duplicate_service.py CHANGED
@@ -7,13 +7,7 @@ import uuid
7
  import os
8
  from typing import Any
9
 
10
- try:
11
- from sentence_transformers import SentenceTransformer, util
12
- _HAS_SENTENCE = True
13
- except Exception: # pragma: no cover - optional runtime dependency
14
- SentenceTransformer = None
15
- util = None
16
- _HAS_SENTENCE = False
17
 
18
  SIMILARITY_THRESHOLD = 0.70
19
 
@@ -38,17 +32,6 @@ class DuplicateService:
38
  return
39
 
40
  print("[DuplicateService] Loading model...")
41
- if not _HAS_SENTENCE:
42
- allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
43
- self._load_failed = True
44
- print("[DuplicateService] sentence-transformers not installed")
45
- if allow_degraded:
46
- print("[DuplicateService] DEGRADED: Continuing without model (ALLOW_DEGRADED_STARTUP=1)")
47
- self.model = None
48
- self._loaded = False
49
- return
50
- else:
51
- raise ImportError("sentence-transformers is required for DuplicateService")
52
  try:
53
  # Check if a local model path is provided
54
  model_path = os.environ.get("SENTENCE_TRANSFORMER_MODEL_PATH")
@@ -118,20 +101,12 @@ class DuplicateService:
118
 
119
  def generate_embedding(self, text: str) -> list[float] | None:
120
  """Generate a 384-d embedding for the provided ticket text."""
121
- from backend.services.redis_cache import redis_cache
122
-
123
- cached = redis_cache.get_embedding(text)
124
- if cached is not None:
125
- return cached
126
-
127
  self.load()
128
  if not self.is_available():
129
  return None
130
 
131
  embedding = self.model.encode(text, convert_to_tensor=False, normalize_embeddings=True)
132
- values = [float(value) for value in embedding.tolist()]
133
- redis_cache.set_embedding(text, values)
134
- return values
135
 
136
  def _build_result(
137
  self,
 
7
  import os
8
  from typing import Any
9
 
10
+ from sentence_transformers import SentenceTransformer, util
 
 
 
 
 
 
11
 
12
  SIMILARITY_THRESHOLD = 0.70
13
 
 
32
  return
33
 
34
  print("[DuplicateService] Loading model...")
 
 
 
 
 
 
 
 
 
 
 
35
  try:
36
  # Check if a local model path is provided
37
  model_path = os.environ.get("SENTENCE_TRANSFORMER_MODEL_PATH")
 
101
 
102
  def generate_embedding(self, text: str) -> list[float] | None:
103
  """Generate a 384-d embedding for the provided ticket text."""
 
 
 
 
 
 
104
  self.load()
105
  if not self.is_available():
106
  return None
107
 
108
  embedding = self.model.encode(text, convert_to_tensor=False, normalize_embeddings=True)
109
+ return [float(value) for value in embedding.tolist()]
 
 
110
 
111
  def _build_result(
112
  self,
backend/services/gemini_service.py CHANGED
@@ -2,7 +2,6 @@ import os
2
  import base64
3
  import io
4
  import re
5
- import json
6
  from PIL import Image
7
  from google import genai
8
  from dotenv import load_dotenv
@@ -223,65 +222,3 @@ class GeminiService:
223
  except Exception as e:
224
  print(f"[GeminiService] Bug Analysis Error: {e}")
225
  return f"Diagnostic analysis failed: {str(e)}"
226
-
227
- def detect_language(self, text: str) -> dict:
228
- """
229
- Detect language for the given text. Returns ISO-ish language code and English language name.
230
- """
231
- if not text or not text.strip():
232
- return {"code": "en", "name": "English"}
233
- if not self._initialized:
234
- return {"code": "en", "name": "English"}
235
-
236
- try:
237
- prompt = (
238
- "Detect the natural language of the following user message. "
239
- "Return strict JSON only with keys: code, name. "
240
- "Example: {\"code\":\"es\",\"name\":\"Spanish\"}.\n\n"
241
- f"Text:\n{text}"
242
- )
243
- response = self.client.models.generate_content(
244
- model=self.model_name,
245
- contents=prompt
246
- )
247
- raw = (response.text or "").strip()
248
- match = re.search(r"\{.*\}", raw, re.DOTALL)
249
- parsed = json.loads(match.group(0) if match else raw)
250
- code = str(parsed.get("code", "en")).lower()
251
- name = str(parsed.get("name", "English"))
252
- if not code:
253
- code = "en"
254
- if not name:
255
- name = "English"
256
- return {"code": code, "name": name}
257
- except Exception as e:
258
- print(f"[GeminiService] Language detection error: {e}")
259
- return {"code": "en", "name": "English"}
260
-
261
- def translate_to_english(self, text: str, source_language: str | None = None) -> str:
262
- """
263
- Translate user text to English while preserving technical terms.
264
- """
265
- if not text or not text.strip():
266
- return text
267
- if not self._initialized:
268
- return text
269
-
270
- try:
271
- lang_hint = f"Source language: {source_language}. " if source_language else ""
272
- prompt = (
273
- "Translate the following support ticket text to natural, concise English. "
274
- "Preserve technical terms, error codes, product names, and formatting. "
275
- "Return only translated text with no prefix or explanation. "
276
- f"{lang_hint}\n\n"
277
- f"Text:\n{text}"
278
- )
279
- response = self.client.models.generate_content(
280
- model=self.model_name,
281
- contents=prompt
282
- )
283
- translated = (response.text or "").strip()
284
- return translated or text
285
- except Exception as e:
286
- print(f"[GeminiService] Translation error: {e}")
287
- return text
 
2
  import base64
3
  import io
4
  import re
 
5
  from PIL import Image
6
  from google import genai
7
  from dotenv import load_dotenv
 
222
  except Exception as e:
223
  print(f"[GeminiService] Bug Analysis Error: {e}")
224
  return f"Diagnostic analysis failed: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/services/metrics_service.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prometheus metrics for AI inference telemetry.
3
+
4
+ Exposes histograms and counters for DistilBERT classification latency,
5
+ total inference requests, and API token counts. Collected by Prometheus
6
+ via the FastAPI /metrics endpoint and visualized in Grafana.
7
+ """
8
+
9
+ from prometheus_client import Counter, Histogram
10
+
11
+ # DistilBERT classification latency (seconds)
12
+ CLASSIFIER_LATENCY = Histogram(
13
+ "ai_classifier_inference_latency_seconds",
14
+ "Latency of DistilBERT classifier inference in seconds",
15
+ labelnames=("model",),
16
+ buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
17
+ )
18
+
19
+ # Total classifier inference requests, by model and status (ok|error)
20
+ CLASSIFIER_REQUESTS = Counter(
21
+ "ai_classifier_inference_requests_total",
22
+ "Total number of classifier inference requests",
23
+ labelnames=("model", "status"),
24
+ )
25
+
26
+ # Token counts processed by the classifier (input tokens)
27
+ CLASSIFIER_TOKENS = Counter(
28
+ "ai_classifier_input_tokens_total",
29
+ "Total number of input tokens processed by the classifier",
30
+ labelnames=("model",),
31
+ )
backend/services/ner_service.py CHANGED
@@ -5,20 +5,12 @@ Labels follow pattern: B-B-ENTITY_TYPE, I-B-ENTITY_TYPE, O
5
 
6
  import os
7
  import json
8
- try:
9
- import torch
10
- import torch.nn.functional as F
11
- from transformers import DistilBertTokenizerFast, DistilBertForTokenClassification
12
- _HAS_TORCH = True
13
- except Exception: # pragma: no cover - optional runtime dependency
14
- torch = None
15
- F = None
16
- DistilBertTokenizerFast = None
17
- DistilBertForTokenClassification = None
18
- _HAS_TORCH = False
19
 
20
  SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "ner")
21
- DEVICE = torch.device("cuda" if torch and torch.cuda.is_available() else "cpu") if _HAS_TORCH else None
22
  MAX_LEN = 128
23
 
24
  import re
@@ -49,10 +41,6 @@ class NERService:
49
  if self._loaded:
50
  return
51
 
52
- if not _HAS_TORCH:
53
- print("[INFO] NER runtime not available; NER model will remain unloaded until dependencies are installed.")
54
- return
55
-
56
  abs_dir = os.path.abspath(SAVE_DIR)
57
 
58
  if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
 
5
 
6
  import os
7
  import json
8
+ import torch
9
+ import torch.nn.functional as F
10
+ from transformers import DistilBertTokenizerFast, DistilBertForTokenClassification
 
 
 
 
 
 
 
 
11
 
12
  SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "ner")
13
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
14
  MAX_LEN = 128
15
 
16
  import re
 
41
  if self._loaded:
42
  return
43
 
 
 
 
 
44
  abs_dir = os.path.abspath(SAVE_DIR)
45
 
46
  if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
backend/services/rag_service.py CHANGED
@@ -1,10 +1,5 @@
1
  import os
2
- try:
3
- from sentence_transformers import SentenceTransformer
4
- _HAS_SENTENCE = True
5
- except Exception: # pragma: no cover - optional runtime dependency
6
- SentenceTransformer = None
7
- _HAS_SENTENCE = False
8
  from supabase import create_client, Client
9
  from dotenv import load_dotenv
10
 
@@ -32,17 +27,6 @@ class RagService:
32
  return
33
 
34
  print("[RAG] Loading SentenceTransformer for Knowledge Base...")
35
- if not _HAS_SENTENCE:
36
- allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
37
- self._load_failed = True
38
- print("[RAG] sentence-transformers not installed")
39
- if allow_degraded:
40
- print("[RAG] DEGRADED: Continuing without model (ALLOW_DEGRADED_STARTUP=1)")
41
- self.model = None
42
- self._loaded = False
43
- return
44
- else:
45
- raise ImportError("sentence-transformers is required for RagService")
46
  try:
47
  # Check if a local model path is provided
48
  model_path = os.environ.get("SENTENCE_TRANSFORMER_MODEL_PATH")
 
1
  import os
2
+ from sentence_transformers import SentenceTransformer
 
 
 
 
 
3
  from supabase import create_client, Client
4
  from dotenv import load_dotenv
5
 
 
27
  return
28
 
29
  print("[RAG] Loading SentenceTransformer for Knowledge Base...")
 
 
 
 
 
 
 
 
 
 
 
30
  try:
31
  # Check if a local model path is provided
32
  model_path = os.environ.get("SENTENCE_TRANSFORMER_MODEL_PATH")
backend/services/sla_service.py CHANGED
@@ -14,10 +14,6 @@ import os
14
  from datetime import datetime, timedelta, timezone
15
  from typing import Any, Callable, Optional
16
 
17
- from fastapi import BackgroundTasks
18
-
19
- from backend.sla_checker import dispatch_slack_alert
20
-
21
  try:
22
  from dotenv import load_dotenv
23
  except ImportError:
@@ -118,7 +114,7 @@ class SlaEscalationService:
118
  self.now_fn = now_fn
119
  self.notification_router = notification_router
120
 
121
- def run_once(self, background_tasks: BackgroundTasks | None = None) -> dict[str, int | str]:
122
  stats: dict[str, int | str] = {
123
  "processed_count": 0,
124
  "breached_count": 0,
@@ -140,7 +136,7 @@ class SlaEscalationService:
140
  if not self._should_breach(ticket, now):
141
  stats["skipped_count"] = int(stats["skipped_count"]) + 1
142
  continue
143
- self._breach_ticket(ticket, now, background_tasks=background_tasks)
144
  stats["breached_count"] = int(stats["breached_count"]) + 1
145
  except Exception as exc:
146
  stats["error_count"] = int(stats["error_count"]) + 1
@@ -173,13 +169,7 @@ class SlaEscalationService:
173
  return False
174
  return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
175
 
176
- def _breach_ticket(
177
- self,
178
- ticket: dict[str, Any],
179
- now: datetime,
180
- *,
181
- background_tasks: BackgroundTasks | None = None,
182
- ) -> None:
183
  ticket_id = str(ticket.get("id"))
184
  company_id = ticket.get("company_id")
185
  escalation_level = int(ticket.get("escalation_level") or 0) + 1
@@ -195,7 +185,6 @@ class SlaEscalationService:
195
 
196
  self._insert_audit_log(ticket, escalation_level, timestamp)
197
  self._emit_system_message(ticket, escalation_level, timestamp)
198
- self._dispatch_breach_alert(ticket, now, background_tasks=background_tasks)
199
 
200
  logger.warning(
201
  "SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
@@ -205,31 +194,6 @@ class SlaEscalationService:
205
  escalation_level,
206
  )
207
 
208
- def _dispatch_breach_alert(
209
- self,
210
- ticket: dict[str, Any],
211
- breach_time: datetime,
212
- *,
213
- background_tasks: BackgroundTasks | None = None,
214
- ) -> None:
215
- ticket_id = str(ticket.get("id") or "")
216
- subject = str(ticket.get("subject") or "Untitled ticket")
217
- category = str(ticket.get("priority") or "Uncategorized")
218
- assignee = str(ticket.get("assigned_team") or "Unassigned")
219
-
220
- if background_tasks is not None:
221
- background_tasks.add_task(
222
- dispatch_slack_alert,
223
- ticket_id,
224
- subject,
225
- category,
226
- assignee,
227
- breach_time,
228
- )
229
- return
230
-
231
- dispatch_slack_alert(ticket_id, subject, category, assignee, breach_time)
232
-
233
  def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
234
  ticket_id = str(ticket.get("id"))
235
  self.supabase.table("audit_logs").insert(
 
14
  from datetime import datetime, timedelta, timezone
15
  from typing import Any, Callable, Optional
16
 
 
 
 
 
17
  try:
18
  from dotenv import load_dotenv
19
  except ImportError:
 
114
  self.now_fn = now_fn
115
  self.notification_router = notification_router
116
 
117
+ def run_once(self) -> dict[str, int | str]:
118
  stats: dict[str, int | str] = {
119
  "processed_count": 0,
120
  "breached_count": 0,
 
136
  if not self._should_breach(ticket, now):
137
  stats["skipped_count"] = int(stats["skipped_count"]) + 1
138
  continue
139
+ self._breach_ticket(ticket, now)
140
  stats["breached_count"] = int(stats["breached_count"]) + 1
141
  except Exception as exc:
142
  stats["error_count"] = int(stats["error_count"]) + 1
 
169
  return False
170
  return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
171
 
172
+ def _breach_ticket(self, ticket: dict[str, Any], now: datetime) -> None:
 
 
 
 
 
 
173
  ticket_id = str(ticket.get("id"))
174
  company_id = ticket.get("company_id")
175
  escalation_level = int(ticket.get("escalation_level") or 0) + 1
 
185
 
186
  self._insert_audit_log(ticket, escalation_level, timestamp)
187
  self._emit_system_message(ticket, escalation_level, timestamp)
 
188
 
189
  logger.warning(
190
  "SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
 
194
  escalation_level,
195
  )
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
198
  ticket_id = str(ticket.get("id"))
199
  self.supabase.table("audit_logs").insert(
deploy/monitoring/grafana_dashboard.json ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "annotations": {
3
+ "list": [
4
+ {
5
+ "builtIn": 1,
6
+ "datasource": {
7
+ "type": "datasource",
8
+ "uid": "grafana"
9
+ },
10
+ "enable": true,
11
+ "hide": true,
12
+ "iconColor": "rgba(0, 211, 255, 1)",
13
+ "name": "Annotations & Alerts",
14
+ "type": "dashboard"
15
+ }
16
+ ]
17
+ },
18
+ "description": "HELPDESK.AI service telemetry — AI inference latency, API throughput, token counts, and host resources.",
19
+ "editable": true,
20
+ "fiscalYearStartMonth": 0,
21
+ "graphTooltip": 0,
22
+ "id": null,
23
+ "links": [],
24
+ "liveNow": false,
25
+ "panels": [
26
+ {
27
+ "datasource": {
28
+ "type": "prometheus",
29
+ "uid": "${DS_PROMETHEUS}"
30
+ },
31
+ "fieldConfig": {
32
+ "defaults": {
33
+ "color": {"mode": "palette-classic"},
34
+ "custom": {
35
+ "axisLabel": "seconds",
36
+ "drawStyle": "line",
37
+ "fillOpacity": 10,
38
+ "lineWidth": 2,
39
+ "showPoints": "never"
40
+ },
41
+ "unit": "s"
42
+ },
43
+ "overrides": []
44
+ },
45
+ "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},
46
+ "id": 1,
47
+ "options": {
48
+ "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
49
+ "tooltip": {"mode": "multi"}
50
+ },
51
+ "title": "DistilBERT Inference Latency (p50 / p95 / p99)",
52
+ "targets": [
53
+ {
54
+ "expr": "histogram_quantile(0.50, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
55
+ "legendFormat": "p50",
56
+ "refId": "A"
57
+ },
58
+ {
59
+ "expr": "histogram_quantile(0.95, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
60
+ "legendFormat": "p95",
61
+ "refId": "B"
62
+ },
63
+ {
64
+ "expr": "histogram_quantile(0.99, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
65
+ "legendFormat": "p99",
66
+ "refId": "C"
67
+ }
68
+ ],
69
+ "type": "timeseries"
70
+ },
71
+ {
72
+ "datasource": {
73
+ "type": "prometheus",
74
+ "uid": "${DS_PROMETHEUS}"
75
+ },
76
+ "fieldConfig": {
77
+ "defaults": {
78
+ "color": {"mode": "palette-classic"},
79
+ "custom": {
80
+ "axisLabel": "req/s",
81
+ "drawStyle": "line",
82
+ "fillOpacity": 10,
83
+ "lineWidth": 2,
84
+ "showPoints": "never"
85
+ },
86
+ "unit": "reqps"
87
+ },
88
+ "overrides": []
89
+ },
90
+ "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},
91
+ "id": 2,
92
+ "options": {
93
+ "legend": {"calcs": ["mean"], "displayMode": "table", "placement": "bottom"},
94
+ "tooltip": {"mode": "multi"}
95
+ },
96
+ "title": "Classifier Request Rate (by status)",
97
+ "targets": [
98
+ {
99
+ "expr": "sum by (status) (rate(ai_classifier_inference_requests_total[1m]))",
100
+ "legendFormat": "{{status}}",
101
+ "refId": "A"
102
+ }
103
+ ],
104
+ "type": "timeseries"
105
+ },
106
+ {
107
+ "datasource": {
108
+ "type": "prometheus",
109
+ "uid": "${DS_PROMETHEUS}"
110
+ },
111
+ "fieldConfig": {
112
+ "defaults": {
113
+ "color": {"mode": "palette-classic"},
114
+ "custom": {
115
+ "axisLabel": "tokens/s",
116
+ "drawStyle": "line",
117
+ "fillOpacity": 10,
118
+ "lineWidth": 2,
119
+ "showPoints": "never"
120
+ },
121
+ "unit": "short"
122
+ },
123
+ "overrides": []
124
+ },
125
+ "gridPos": {"h": 9, "w": 12, "x": 0, "y": 9},
126
+ "id": 3,
127
+ "options": {
128
+ "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
129
+ "tooltip": {"mode": "multi"}
130
+ },
131
+ "title": "Token Throughput (input tokens / sec)",
132
+ "targets": [
133
+ {
134
+ "expr": "sum by (model) (rate(ai_classifier_input_tokens_total[1m]))",
135
+ "legendFormat": "{{model}}",
136
+ "refId": "A"
137
+ }
138
+ ],
139
+ "type": "timeseries"
140
+ },
141
+ {
142
+ "datasource": {
143
+ "type": "prometheus",
144
+ "uid": "${DS_PROMETHEUS}"
145
+ },
146
+ "fieldConfig": {
147
+ "defaults": {
148
+ "color": {"mode": "palette-classic"},
149
+ "custom": {
150
+ "axisLabel": "bytes",
151
+ "drawStyle": "line",
152
+ "fillOpacity": 10,
153
+ "lineWidth": 2,
154
+ "showPoints": "never"
155
+ },
156
+ "unit": "decbytes"
157
+ },
158
+ "overrides": []
159
+ },
160
+ "gridPos": {"h": 9, "w": 12, "x": 12, "y": 9},
161
+ "id": 4,
162
+ "options": {
163
+ "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
164
+ "tooltip": {"mode": "multi"}
165
+ },
166
+ "title": "Process Memory (RSS)",
167
+ "targets": [
168
+ {
169
+ "expr": "process_resident_memory_bytes",
170
+ "legendFormat": "{{instance}}",
171
+ "refId": "A"
172
+ }
173
+ ],
174
+ "type": "timeseries"
175
+ },
176
+ {
177
+ "datasource": {
178
+ "type": "prometheus",
179
+ "uid": "${DS_PROMETHEUS}"
180
+ },
181
+ "fieldConfig": {
182
+ "defaults": {
183
+ "color": {"mode": "palette-classic"},
184
+ "custom": {
185
+ "axisLabel": "CPU seconds/sec",
186
+ "drawStyle": "line",
187
+ "fillOpacity": 10,
188
+ "lineWidth": 2,
189
+ "showPoints": "never"
190
+ },
191
+ "unit": "percentunit"
192
+ },
193
+ "overrides": []
194
+ },
195
+ "gridPos": {"h": 9, "w": 24, "x": 0, "y": 18},
196
+ "id": 5,
197
+ "options": {
198
+ "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
199
+ "tooltip": {"mode": "multi"}
200
+ },
201
+ "title": "Process CPU Usage",
202
+ "targets": [
203
+ {
204
+ "expr": "rate(process_cpu_seconds_total[1m])",
205
+ "legendFormat": "{{instance}}",
206
+ "refId": "A"
207
+ }
208
+ ],
209
+ "type": "timeseries"
210
+ }
211
+ ],
212
+ "refresh": "10s",
213
+ "schemaVersion": 38,
214
+ "style": "dark",
215
+ "tags": ["helpdesk-ai", "fastapi", "ai-inference", "prometheus"],
216
+ "templating": {
217
+ "list": [
218
+ {
219
+ "current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
220
+ "hide": 0,
221
+ "includeAll": false,
222
+ "label": "Datasource",
223
+ "multi": false,
224
+ "name": "DS_PROMETHEUS",
225
+ "options": [],
226
+ "query": "prometheus",
227
+ "refresh": 1,
228
+ "regex": "",
229
+ "skipUrlSync": false,
230
+ "type": "datasource"
231
+ }
232
+ ]
233
+ },
234
+ "time": {"from": "now-1h", "to": "now"},
235
+ "timepicker": {},
236
+ "timezone": "",
237
+ "title": "HELPDESK.AI — Service Telemetry",
238
+ "uid": "helpdesk-ai-telemetry",
239
+ "version": 1,
240
+ "weekStart": ""
241
+ }
requirements.txt CHANGED
@@ -4,8 +4,6 @@ datasets>=2.14.0
4
  pandas>=2.0.0
5
  scikit-learn>=1.3.0
6
  sentence-transformers>=2.2.0
7
- onnxruntime>=1.17.0
8
- tokenizers>=0.15.0
9
  fastapi>=0.104.0
10
  uvicorn>=0.24.0
11
  openpyxl>=3.1.0
@@ -17,8 +15,4 @@ easyocr
17
  slowapi>=0.1.9
18
  supabase==2.22.4
19
  storage3==2.22.4
20
- redis>=5.0.0
21
- pytest
22
- pytest-asyncio
23
- httpx
24
-
 
4
  pandas>=2.0.0
5
  scikit-learn>=1.3.0
6
  sentence-transformers>=2.2.0
 
 
7
  fastapi>=0.104.0
8
  uvicorn>=0.24.0
9
  openpyxl>=3.1.0
 
15
  slowapi>=0.1.9
16
  supabase==2.22.4
17
  storage3==2.22.4
18
+ prometheus-client>=0.19.0
 
 
 
 
supabase/functions/send-user-approval-email/index.ts CHANGED
@@ -1,144 +1,69 @@
1
- import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
2
 
3
  const corsHeaders = {
4
- "Access-Control-Allow-Origin": "*",
5
- "Access-Control-Allow-Methods": "POST, OPTIONS",
6
- "Access-Control-Allow-Headers":
7
- "authorization, x-client-info, apikey, content-type",
8
  };
9
 
10
- const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY") || "";
11
- const FROM_EMAIL =
12
- Deno.env.get("APPROVAL_FROM_EMAIL") ||
13
- Deno.env.get("RESEND_FROM_EMAIL") ||
14
- "HELPDESK.AI <bonthalamadhavi1@gmail.com>";
15
- const DASHBOARD_URL =
16
- Deno.env.get("FRONTEND_DASHBOARD_URL") ||
17
- Deno.env.get("FRONTEND_URL") ||
18
- "https://helpdeskaiv1.vercel.app/dashboard";
19
-
20
- function buildApprovalHtml(name: string, company: string): string {
21
- const safeName = name || "there";
22
- const safeCompany = company || "your organization";
23
- return `<!DOCTYPE html>
24
- <html>
25
- <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
26
- <body style="margin:0;padding:0;background-color:#f1f5f9;font-family:sans-serif;">
27
- <table width="100%" cellspacing="0" cellpadding="0" style="background-color:#f1f5f9;padding:40px 20px;">
28
- <tr><td align="center">
29
- <table width="100%" style="max-width:600px;background:#ffffff;border-radius:24px;overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,0.05);">
30
- <tr>
31
- <td style="background:linear-gradient(135deg,#059669 0%,#047857 100%);padding:40px;text-align:center;">
32
- <h1 style="color:#ffffff;margin:0;font-size:28px;font-weight:900;">HELPDESK<span style="color:#a7f3d0;">.AI</span></h1>
33
- <p style="color:#d1fae5;margin:8px 0 0;font-size:14px;">Account Approved</p>
34
- </td>
35
- </tr>
36
- <tr>
37
- <td style="padding:40px;">
38
- <h2 style="color:#0f172a;font-size:24px;margin:0 0 16px;">Hello ${safeName},</h2>
39
- <p style="color:#64748b;font-size:16px;line-height:1.7;margin:0 0 24px;">
40
- Your account for <strong>${safeCompany}</strong> has been approved by your administrator.
41
- You can now sign in and access your dashboard.
42
- </p>
43
- <div align="center">
44
- <a href="${DASHBOARD_URL}" style="display:inline-block;background-color:#059669;color:#ffffff;padding:16px 32px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:800;">
45
- Go to Dashboard →
46
- </a>
47
- </div>
48
- </td>
49
- </tr>
50
- <tr>
51
- <td style="background-color:#f8fafc;padding:24px;text-align:center;border-top:1px solid #f1f5f9;">
52
- <p style="margin:0;color:#94a3b8;font-size:12px;">© 2026 HELPDESK.AI</p>
53
- </td>
54
- </tr>
55
- </table>
56
- </td></tr>
57
- </table>
58
- </body>
59
- </html>`;
60
- }
61
-
62
  serve(async (req) => {
63
- if (req.method === "OPTIONS") {
64
- return new Response(null, { status: 204, headers: corsHeaders });
65
- }
66
-
67
- try {
68
- const { userId, email, name, company } = await req.json();
69
-
70
- if (!email) {
71
- throw new Error("Email is required");
72
  }
73
 
74
- if (!RESEND_API_KEY) {
75
- console.warn(
76
- `[send-user-approval-email] RESEND_API_KEY missing — cannot send to ${email}`,
77
- );
78
- return new Response(
79
- JSON.stringify({
80
- success: false,
81
- error: "RESEND_API_KEY is not configured",
82
- userId: userId ?? null,
83
- }),
84
- {
85
- status: 503,
86
- headers: { ...corsHeaders, "Content-Type": "application/json" },
87
- },
88
- );
89
- }
90
 
91
- console.log(
92
- `[send-user-approval-email] Sending approval email to ${email} (user=${userId ?? "unknown"})`,
93
- );
94
 
95
- const res = await fetch("https://api.resend.com/emails", {
96
- method: "POST",
97
- headers: {
98
- "Content-Type": "application/json",
99
- Authorization: `Bearer ${RESEND_API_KEY}`,
100
- },
101
- body: JSON.stringify({
102
- from: FROM_EMAIL,
103
- to: [email],
104
- subject: "Account Approved — Welcome to HELPDESK.AI",
105
- html: buildApprovalHtml(name, company),
106
- }),
107
- });
108
 
109
- const data = await res.json();
110
 
111
- if (!res.ok) {
112
- console.error("[send-user-approval-email] Resend error:", data);
113
- return new Response(
114
- JSON.stringify({
115
- success: false,
116
- error: data?.message || "Failed to send approval email",
117
- details: data,
118
- }),
119
- {
120
- status: res.status,
121
- headers: { ...corsHeaders, "Content-Type": "application/json" },
122
- },
123
- );
124
- }
 
 
 
 
 
 
 
125
 
126
- return new Response(
127
- JSON.stringify({
128
- success: true,
129
- message: `Approval email sent to ${email}`,
130
- resendId: data?.id ?? null,
131
- }),
132
- {
133
- status: 200,
134
- headers: { ...corsHeaders, "Content-Type": "application/json" },
135
- },
136
- );
137
- } catch (error) {
138
- const message = error instanceof Error ? error.message : String(error);
139
- return new Response(JSON.stringify({ success: false, error: message }), {
140
- status: 400,
141
- headers: { ...corsHeaders, "Content-Type": "application/json" },
142
- });
143
- }
 
144
  });
 
1
+ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
2
 
3
  const corsHeaders = {
4
+ 'Access-Control-Allow-Origin': '*',
5
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
6
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
 
7
  };
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  serve(async (req) => {
10
+ // Handle CORS preflight requests
11
+ if (req.method === 'OPTIONS') {
12
+ return new Response(null, { status: 204, headers: corsHeaders });
 
 
 
 
 
 
13
  }
14
 
15
+ try {
16
+ const { userId, email, name, company } = await req.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
+ if (!email) {
19
+ throw new Error('Email is required');
20
+ }
21
 
22
+ // Initialize Resend or another mail provider (Assuming user configures this later)
23
+ // const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
24
+ // Currently simulating a successfully sent email.
 
 
 
 
 
 
 
 
 
 
25
 
26
+ console.log(`Sending approval email to ${email} for user ${name} in company ${company}...`);
27
 
28
+ // The email content could be sent via Resend:
29
+ /*
30
+ const res = await fetch('https://api.resend.com/emails', {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ 'Authorization': `Bearer ${RESEND_API_KEY}`,
35
+ },
36
+ body: JSON.stringify({
37
+ from: 'HelpDesk.ai <noreply@yourdomain.com>',
38
+ to: [email],
39
+ subject: 'Account Approved! Welcome to HelpDesk.ai',
40
+ html: `
41
+ <h2>Hello ${name},</h2>
42
+ <p>Your account for <strong>${company}</strong> has been approved by your administrator!</p>
43
+ <p>You can now log in to the system and access your dashboard.</p>
44
+ <a href="https://your-frontend-url.com/dashboard" style="background-color: #059669; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Go to Dashboard</a>
45
+ `
46
+ })
47
+ });
48
+ */
49
 
50
+ return new Response(
51
+ JSON.stringify({
52
+ success: true,
53
+ message: `Approval email simulated for ${email}`
54
+ }),
55
+ {
56
+ status: 200,
57
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' }
58
+ }
59
+ );
60
+ } catch (error) {
61
+ return new Response(
62
+ JSON.stringify({ error: error.message }),
63
+ {
64
+ status: 400,
65
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' }
66
+ }
67
+ );
68
+ }
69
  });
supabase/migrations/20260522080000_add_sla_escalation.sql CHANGED
@@ -43,7 +43,7 @@ CREATE POLICY "Service role full audit access" ON audit_logs
43
  CREATE POLICY "Admins can view own company audit logs" ON audit_logs
44
  FOR SELECT USING (
45
  company_id IN (
46
- SELECT company_id FROM profiles WHERE id = auth.uid()
47
  )
48
  );
49
 
 
43
  CREATE POLICY "Admins can view own company audit logs" ON audit_logs
44
  FOR SELECT USING (
45
  company_id IN (
46
+ SELECT company_id FROM user_companies WHERE user_id = auth.uid()
47
  )
48
  );
49
 
supabase/migrations/20260531_add_company_settings.sql CHANGED
@@ -17,23 +17,6 @@ CREATE TABLE system_settings (
17
  admin_alerts BOOLEAN NOT NULL DEFAULT TRUE,
18
  digest_frequency TEXT NOT NULL DEFAULT 'daily' CHECK (digest_frequency IN ('daily', 'weekly')),
19
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
20
- -- Create system_settings table for storing per-company system configuration
21
-
22
- -- NOTE: migration filename contains 'add_company_settings'. The migration now creates
23
- -- `system_settings` per the unified schema; filename unchanged for migration ordering.
24
-
25
- CREATE TABLE IF NOT EXISTS system_settings (
26
- company_id UUID PRIMARY KEY,
27
- ai_confidence_threshold FLOAT DEFAULT 0.80,
28
- duplicate_sensitivity FLOAT DEFAULT 0.85,
29
- enable_auto_resolve BOOLEAN DEFAULT FALSE,
30
- auto_close_enabled BOOLEAN DEFAULT TRUE,
31
- auto_close_days INTEGER DEFAULT 7,
32
- email_notifications BOOLEAN DEFAULT TRUE,
33
- admin_alerts BOOLEAN DEFAULT TRUE,
34
- digest_frequency TEXT DEFAULT 'daily',
35
- created_at TIMESTAMPTZ DEFAULT NOW(),
36
- updated_at TIMESTAMPTZ DEFAULT NOW()
37
  );
38
 
39
  -- Enable Row Level Security
@@ -54,49 +37,6 @@ CREATE POLICY "Company members can manage own settings" ON system_settings
54
  )
55
  );
56
 
57
- CREATE POLICY "Admins can view own company settings" ON system_settings
58
- FOR SELECT USING (
59
- company_id IN (
60
- SELECT company_id FROM profiles
61
- WHERE id = auth.uid()
62
- AND role IN ('admin', 'super_admin', 'master_admin')
63
- )
64
- );
65
-
66
- -- Admins can create and update settings for their own company from the dashboard.
67
- CREATE POLICY "Admins can insert own company settings" ON system_settings
68
- FOR INSERT WITH CHECK (
69
- company_id IN (
70
- SELECT company_id FROM profiles
71
- WHERE id = auth.uid()
72
- AND role IN ('admin', 'super_admin', 'master_admin')
73
- )
74
- );
75
-
76
- CREATE POLICY "Admins can update own company settings" ON system_settings
77
- FOR UPDATE USING (
78
- company_id IN (
79
- SELECT company_id FROM profiles
80
- WHERE id = auth.uid()
81
- AND role IN ('admin', 'super_admin', 'master_admin')
82
- )
83
- )
84
- WITH CHECK (
85
- company_id IN (
86
- SELECT company_id FROM profiles
87
- WHERE id = auth.uid()
88
- AND role IN ('admin', 'super_admin', 'master_admin')
89
- )
90
- );
91
-
92
- CREATE OR REPLACE FUNCTION update_timestamp()
93
- RETURNS TRIGGER AS $$
94
- BEGIN
95
- NEW.updated_at = NOW();
96
- RETURN NEW;
97
- END;
98
- $$ LANGUAGE plpgsql;
99
-
100
  -- Trigger to auto-update updated_at on modification
101
  CREATE OR REPLACE FUNCTION update_system_settings_timestamp()
102
  RETURNS TRIGGER LANGUAGE plpgsql AS $$
 
17
  admin_alerts BOOLEAN NOT NULL DEFAULT TRUE,
18
  digest_frequency TEXT NOT NULL DEFAULT 'daily' CHECK (digest_frequency IN ('daily', 'weekly')),
19
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  );
21
 
22
  -- Enable Row Level Security
 
37
  )
38
  );
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  -- Trigger to auto-update updated_at on modification
41
  CREATE OR REPLACE FUNCTION update_system_settings_timestamp()
42
  RETURNS TRIGGER LANGUAGE plpgsql AS $$