ritesh19180 commited on
Commit
af05c67
·
verified ·
1 Parent(s): 82cae4f

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +0 -23
  2. .gitignore +1 -11
  3. Dockerfile +19 -23
  4. Frontend/eslint.config.js +0 -21
  5. Frontend/package-lock.json +0 -0
  6. Frontend/package.json +3 -17
  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 +19 -48
  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/legacy_ui/Dashboard.jsx +2 -2
  18. Frontend/src/pages/AdminSignup.jsx +3 -18
  19. Frontend/src/pages/LandingPage.jsx +41 -44
  20. Frontend/src/pages/Signup.jsx +4 -19
  21. Frontend/src/services/aiAssistant.js +69 -88
  22. Frontend/src/services/api.js +19 -50
  23. Frontend/src/store/authStore.js +31 -87
  24. Frontend/src/store/ticketStore.js +14 -60
  25. Frontend/src/user/components/RecentTickets.jsx +0 -5
  26. Frontend/src/user/pages/AutoResolveChat.jsx +2 -56
  27. Frontend/src/user/pages/CreateTicket.jsx +1 -1
  28. Frontend/src/user/pages/MyTickets.jsx +0 -13
  29. Frontend/src/user/pages/TicketDetail.jsx +0 -26
  30. Frontend/vite.config.js +1 -1
  31. MobileApp/App.js +18 -77
  32. MobileApp/package-lock.json +1 -1
  33. MobileApp/package.json +1 -1
  34. MobileApp/src/lib/supabase.js +2 -8
  35. MobileApp/src/screens/auth/LoginScreen.js +0 -2
  36. MobileApp/src/screens/user/ProfileScreen.js +0 -3
  37. README.md +1 -3
  38. backend/.env.example +13 -101
  39. backend/Dockerfile +19 -23
  40. backend/auth/crypto.py +88 -208
  41. backend/main.py +360 -1016
  42. backend/requirements.txt +1 -10
  43. backend/services/classifier_service.py +97 -95
  44. backend/services/classifier_v2.py +68 -88
  45. backend/services/classifier_v3.py +58 -78
  46. backend/services/duplicate_service.py +2 -27
  47. backend/services/gemini_service.py +7 -85
  48. backend/services/ner_service.py +4 -16
  49. backend/services/rag_service.py +1 -17
  50. backend/services/sla_service.py +4 -41
.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
@@ -4,10 +4,7 @@ __pycache__/
4
  *$py.class
5
  venv/
6
  env/
7
- # Ignore all env files anywhere in the repo
8
- **/.env
9
- **/.env.*
10
-
11
 
12
  # Models (Keep them if they are small, but here they are large.
13
  # We should probably only keep the ones we actually use if possible,
@@ -37,10 +34,3 @@ docs/logs/
37
  scratch/
38
  .npm-cache/
39
 
40
- # GSSoC Score Tracking and Temporary Scripts
41
- scratch/
42
- .npm-cache/
43
- *.tmp
44
- ../MOBILE_SETUP_TEMP.md
45
- ../CI_YML_TEMP.yml
46
- ../ENV_EXAMPLE_TEMP.example
 
4
  *$py.class
5
  venv/
6
  env/
7
+ .env
 
 
 
8
 
9
  # Models (Keep them if they are small, but here they are large.
10
  # We should probably only keep the ones we actually use if possible,
 
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
@@ -6,12 +6,10 @@
6
  "scripts": {
7
  "dev": "vite",
8
  "build": "vite build",
9
- "lint": "eslint . --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
  }
 
6
  "scripts": {
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
@@ -1,13 +1,12 @@
1
  import React, { useMemo } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
- import { Activity, AlertTriangle, Clock, ShieldCheck, Wifi, WifiOff } from 'lucide-react';
4
 
5
  import useAuthStore from "../../store/authStore";
6
- import useTicketStore from "../../store/ticketStore";
7
- import useWebSocket from "../../hooks/useWebSocket";
8
  import { supabase } from "../../lib/supabaseClient";
9
  import StatCard from "../components/StatCard";
10
  import TicketTable from "../components/TicketTable";
 
11
 
12
  // Inline SVG icon components
13
  const TicketIcon = () => (
@@ -104,31 +103,10 @@ function formatSlaCountdown(deadlineMs, nowMs) {
104
  const AdminDashboard = () => {
105
  const navigate = useNavigate();
106
  const { profile } = useAuthStore();
 
107
  const [isLoading, setIsLoading] = React.useState(true);
108
  const [nowMs, setNowMs] = React.useState(() => Date.now());
109
 
110
- // WebSocket connection for real-time ticket updates
111
- const { isConnected: wsConnected, lastMessage } = useWebSocket(profile?.company);
112
-
113
- // Read tickets from the Zustand store (populated below and updated by WS)
114
- const tickets = useTicketStore((s) => s.tickets);
115
- const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
116
- const setWsConnected = useTicketStore((s) => s.setWsConnected);
117
- const upsertTicket = useTicketStore((s) => s.upsertTicket);
118
-
119
- // Sync WebSocket connection status to store
120
- React.useEffect(() => {
121
- setWsConnected(wsConnected);
122
- }, [wsConnected, setWsConnected]);
123
-
124
- // Route incoming WebSocket messages into the ticket store
125
- React.useEffect(() => {
126
- if (lastMessage) {
127
- handleWsMessage(lastMessage);
128
- }
129
- }, [lastMessage, handleWsMessage]);
130
-
131
- // Initial fetch — populate store from Supabase on mount
132
  React.useEffect(() => {
133
  if (profile) {
134
  const fetchStats = async () => {
@@ -148,22 +126,19 @@ const AdminDashboard = () => {
148
  console.warn("Retrying dashboard fetch without relation...", error);
149
  const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
150
  if (basicError) throw basicError;
151
- // Bulk-load into store (avoid duplicates)
152
- for (const t of basicData || []) {
153
- upsertTicket(t);
154
- }
155
  } else {
156
- for (const t of data || []) {
157
- upsertTicket(t);
158
- }
159
  }
160
  } catch (err) { console.error("Dashboard fetch error:", err); }
161
  finally { setIsLoading(false); }
162
  };
163
 
164
  fetchStats();
 
 
165
  }
166
- }, [profile, upsertTicket]);
167
 
168
  React.useEffect(() => {
169
  const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
@@ -219,17 +194,13 @@ const AdminDashboard = () => {
219
  Dashboard
220
  </h1>
221
  <p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
222
- {wsConnected ? (
223
- <Wifi size={14} color="#22c55e" />
224
- ) : (
225
- <WifiOff size={14} color="#f97316" />
226
- )}
227
- {wsConnected ? 'WebSocket connected' : 'Reconnecting...'}
228
  </p>
229
  </div>
230
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: wsConnected ? '#F0FDF4' : '#FFF7ED', border: wsConnected ? '1.5px solid #BBF7D0' : '1.5px solid #FED7AA', borderRadius: '100px' }}>
231
- <span style={{ width: 6, height: 6, borderRadius: '50%', background: wsConnected ? '#22c55e' : '#f97316', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
232
- <span style={{ fontSize: '11px', fontWeight: 700, color: wsConnected ? '#15803d' : '#c2410c', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{wsConnected ? 'Live' : 'Reconnecting'}</span>
233
  </div>
234
  </div>
235
 
@@ -306,9 +277,9 @@ const AdminDashboard = () => {
306
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
307
  AI Status
308
  </h2>
309
- <div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: wsConnected ? '#F0FDF4' : '#FFF7ED', border: wsConnected ? '1px solid #BBF7D0' : '1px solid #FED7AA', borderRadius: '100px', padding: '3px 10px' }}>
310
- <span style={{ width: 5, height: 5, borderRadius: '50%', background: wsConnected ? '#22c55e' : '#f97316', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
311
- <span style={{ fontSize: '10px', fontWeight: 700, color: wsConnected ? '#15803d' : '#c2410c' }}>{wsConnected ? 'WS CONNECTED' : 'RECONNECTING'}</span>
312
  </div>
313
  </div>
314
  <div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
@@ -333,9 +304,9 @@ const AdminDashboard = () => {
333
  <div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
334
  <p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
335
  <div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
336
- {wsConnected ? <Activity size={10} color="#22c55e" /> : <Activity size={10} color="#f97316" />}
337
- <span style={{ fontSize: '9px', fontWeight: 600, color: wsConnected ? '#16a34a' : '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
338
- {wsConnected ? 'Live via WebSocket' : 'Reconnecting via WebSocket...'}
339
  </span>
340
  </div>
341
  </div>
 
1
  import React, { useMemo } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
+ import { Activity, AlertTriangle, Clock, ShieldCheck } from 'lucide-react';
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";
10
 
11
  // Inline SVG icon components
12
  const TicketIcon = () => (
 
103
  const AdminDashboard = () => {
104
  const navigate = useNavigate();
105
  const { profile } = useAuthStore();
106
+ const [tickets, setTickets] = React.useState([]);
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 () => {
 
126
  console.warn("Retrying dashboard fetch without relation...", error);
127
  const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
128
  if (basicError) throw basicError;
129
+ setTickets(basicData || []);
 
 
 
130
  } else {
131
+ setTickets(data || []);
 
 
132
  }
133
  } catch (err) { console.error("Dashboard fetch error:", err); }
134
  finally { setIsLoading(false); }
135
  };
136
 
137
  fetchStats();
138
+ const interval = setInterval(fetchStats, 30000);
139
+ return () => clearInterval(interval);
140
  }
141
+ }, [profile]);
142
 
143
  React.useEffect(() => {
144
  const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
 
194
  Dashboard
195
  </h1>
196
  <p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
197
+ <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'inline-block' }}></span>
198
+ Real-time updates active
 
 
 
 
199
  </p>
200
  </div>
201
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: '#F0FDF4', border: '1.5px solid #BBF7D0', borderRadius: '100px' }}>
202
+ <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
203
+ <span style={{ fontSize: '11px', fontWeight: 700, color: '#15803d', letterSpacing: '0.08em', textTransform: 'uppercase' }}>System Active</span>
204
  </div>
205
  </div>
206
 
 
277
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
278
  AI Status
279
  </h2>
280
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: '100px', padding: '3px 10px' }}>
281
+ <span style={{ width: 5, height: 5, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
282
+ <span style={{ fontSize: '10px', fontWeight: 700, color: '#15803d' }}>LIVE SYNC</span>
283
  </div>
284
  </div>
285
  <div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
 
304
  <div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
305
  <p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
306
  <div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
307
+ <Activity size={10} color="#9ca3af" />
308
+ <span style={{ fontSize: '9px', fontWeight: 600, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
309
+ Last Synced: {formatTimelineDate(new Date())}
310
  </span>
311
  </div>
312
  </div>
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">
@@ -380,22 +373,8 @@ const AdminTicketDetail = () => {
380
  <span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
381
  </div>
382
  <div style={{ padding: '28px' }}>
383
- {isTranslated && (
384
- <div style={{ marginBottom: '16px', border: '1px solid #bae6fd', background: '#f0f9ff', borderRadius: '12px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
385
- <span style={{ fontSize: '12px', fontWeight: 600, color: '#0c4a6e' }}>
386
- Translated from {sourceLanguageName}
387
- </span>
388
- <button
389
- type="button"
390
- onClick={() => setShowOriginalText(prev => !prev)}
391
- style={{ fontSize: '11px', fontWeight: 700, color: '#0369a1', background: 'transparent', border: 'none', cursor: 'pointer' }}
392
- >
393
- {showOriginalText ? 'View English' : 'View Original'}
394
- </button>
395
- </div>
396
- )}
397
  <div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
398
- "{renderedText}"
399
  </div>
400
 
401
  {imageUrl && (
@@ -424,8 +403,6 @@ const AdminTicketDetail = () => {
424
  </div>
425
  <TicketTimeline ticket={ticket} />
426
  </div>
427
-
428
- <TicketAuditTimeline ticketId={ticket.id} companyId={ticket.company_id} />
429
  </div>
430
 
431
  {/* 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">
 
373
  <span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
374
  </div>
375
  <div style={{ padding: '28px' }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  <div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
377
+ "{displayText}"
378
  </div>
379
 
380
  {imageUrl && (
 
403
  </div>
404
  <TicketTimeline ticket={ticket} />
405
  </div>
 
 
406
  </div>
407
 
408
  {/* 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>
@@ -352,11 +359,6 @@ const AdminTickets = () => {
352
  {ticket.category}
353
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
354
  </span>
355
- {ticket?.metadata?.translation?.translated && (
356
- <span className="text-[10px] text-sky-700 mt-1">
357
- Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
358
- </span>
359
- )}
360
  </div>
361
  </td>
362
 
@@ -472,8 +474,7 @@ const AdminTickets = () => {
472
  </div>
473
  </td>
474
  </tr>
475
- );
476
- })}
477
  </tbody>
478
  </table>
479
  </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>
 
359
  {ticket.category}
360
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
361
  </span>
 
 
 
 
 
362
  </div>
363
  </td>
364
 
 
474
  </div>
475
  </td>
476
  </tr>
477
+ ))}
 
478
  </tbody>
479
  </table>
480
  </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/legacy_ui/Dashboard.jsx CHANGED
@@ -63,7 +63,7 @@ const Dashboard = () => {
63
  }
64
  };
65
  fetchTickets();
66
-
67
  }, []);
68
 
69
  // Detect newly inserted or updated tickets for highlight animation
@@ -84,7 +84,7 @@ const Dashboard = () => {
84
  });
85
 
86
  prevTicketsRef.current = tickets;
87
-
88
  }, [tickets]);
89
 
90
  // Summary Counts
 
63
  }
64
  };
65
  fetchTickets();
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
  }, []);
68
 
69
  // Detect newly inserted or updated tickets for highlight animation
 
84
  });
85
 
86
  prevTicketsRef.current = tickets;
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
  }, [tickets]);
89
 
90
  // Summary Counts
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,55 +1,10 @@
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 getProfileCache = (profile) => {
41
- if (!profile?.id) return null;
42
-
43
- return {
44
- id: profile.id,
45
- email: profile.email,
46
- full_name: profile.full_name,
47
- company: profile.company,
48
- company_id: profile.company_id,
49
- profile_picture: profile.profile_picture,
50
- };
51
- };
52
-
53
  const useAuthStore = create(
54
  persist(
55
  (set, get) => ({
@@ -66,25 +21,38 @@ const useAuthStore = create(
66
  if (!user) return null;
67
 
68
  const metadata = user.user_metadata || {};
69
- set({ profile: null });
70
-
71
- // Always resolve authorization fields from the database. Local storage and
72
- // user_metadata are client-controlled surfaces and must not grant roles.
73
- const dbProfile = await get()._syncProfile(user.id);
74
- if (dbProfile) {
75
- return dbProfile;
 
 
 
76
  }
77
 
 
 
 
78
  const instantProfile = {
79
  id: user.id,
80
  email: user.email,
81
- full_name: metadata.full_name || 'User',
82
- role: 'user',
83
- status: 'pending_email_verification',
84
  company: metadata.company || ''
85
  };
86
 
87
- console.log("Falling back to non-authoritative profile:", instantProfile.role);
 
 
 
 
 
 
 
88
  set({ profile: instantProfile });
89
  return instantProfile;
90
  },
@@ -121,19 +89,13 @@ 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
- await get().getProfile(cookieUser);
128
- return cookieUser;
129
- }
130
-
131
  const { data: { user }, error } = await supabase.auth.getUser();
132
  if (error) throw error;
133
 
134
  if (user) {
135
  set({ user });
136
- await get().getProfile(user);
 
137
  } else {
138
  set({ user: null, profile: null });
139
  }
@@ -155,8 +117,6 @@ const useAuthStore = create(
155
  set({ loading: true });
156
  console.log("Attempting login for:", email);
157
  try {
158
- await mirrorBackendAuth('/auth/login', { email, password });
159
-
160
  const { data, error } = await supabase.auth.signInWithPassword({
161
  email,
162
  password,
@@ -245,14 +205,6 @@ const useAuthStore = create(
245
  console.log("Starting signup for:", email);
246
 
247
  try {
248
- await mirrorBackendAuth('/auth/signup', {
249
- email,
250
- password,
251
- full_name: fullName,
252
- role,
253
- company,
254
- });
255
-
256
  // 1. Auth Signup with Metadata
257
  console.log("Step 1: Auth.signUp...");
258
  const { data, error } = await supabase.auth.signUp({
@@ -293,15 +245,6 @@ const useAuthStore = create(
293
  logout: async () => {
294
  set({ loading: true });
295
  try {
296
- try {
297
- await fetch(`${BACKEND_URL}/auth/logout`, {
298
- method: 'POST',
299
- credentials: 'include',
300
- });
301
- } catch (e) {
302
- console.warn('Backend cookie logout failed:', e?.message || e);
303
- }
304
-
305
  const { error } = await supabase.auth.signOut();
306
  if (error) throw error;
307
  set({ user: null, profile: null });
@@ -350,8 +293,8 @@ const useAuthStore = create(
350
  supabase.auth.onAuthStateChange(async (event, session) => {
351
  console.log("Auth state change:", event);
352
  if (session?.user) {
353
- set({ user: session.user, loading: true, isCheckingSession: true });
354
- await get().getProfile(session.user);
355
  } else {
356
  set({ user: null, profile: null });
357
  }
@@ -362,8 +305,9 @@ const useAuthStore = create(
362
  {
363
  name: 'auth-storage',
364
  partialize: (state) => ({
365
- // Cache display-only profile fields. Role/status must come from the DB.
366
- profile: getProfileCache(state.profile)
 
367
  }),
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(
9
  persist(
10
  (set, get) => ({
 
21
  if (!user) return null;
22
 
23
  const metadata = user.user_metadata || {};
24
+ const currentProfile = get().profile;
25
+
26
+ // 1. Resolve FROM METADATA or PERSISTED state
27
+ // Priority 1: If we have a persisted session for THIS user and it's active, keep it
28
+ // to prevent temporary lobbies during refresh/tab switching.
29
+ if (currentProfile && currentProfile.id === user.id && currentProfile.status === 'active') {
30
+ console.log("Active profile retained from state.");
31
+ // Background fetch to ensure session is still valid/synced
32
+ get()._syncProfile(user.id);
33
+ return currentProfile;
34
  }
35
 
36
+ // Priority 2: Use Auth Metadata (Instant fallback)
37
+ const isMasterAdmin = user.email === 'masteradmin@helpdesk.ai';
38
+
39
  const instantProfile = {
40
  id: user.id,
41
  email: user.email,
42
+ full_name: isMasterAdmin ? 'Master Admin' : (metadata.full_name || 'User'),
43
+ role: isMasterAdmin ? 'master_admin' : (metadata.role || 'user'),
44
+ status: isMasterAdmin ? 'active' : 'pending_email_verification',
45
  company: metadata.company || ''
46
  };
47
 
48
+ // 2. Sync with Database First before setting a fallback
49
+ // This prevents flashes of 'pending_email_verification' when returning from magic links
50
+ const dbProfile = await get()._syncProfile(user.id);
51
+ if (dbProfile) {
52
+ return dbProfile;
53
+ }
54
+
55
+ console.log("Falling back to instant profile resolved from metadata:", instantProfile.role);
56
  set({ profile: instantProfile });
57
  return instantProfile;
58
  },
 
89
  currentUserPromise = (async () => {
90
  try {
91
  set({ isCheckingSession: true });
 
 
 
 
 
 
 
92
  const { data: { user }, error } = await supabase.auth.getUser();
93
  if (error) throw error;
94
 
95
  if (user) {
96
  set({ user });
97
+ // Don't 'await' here because we want 'loading: false' ASAP
98
+ get().getProfile(user);
99
  } else {
100
  set({ user: null, profile: null });
101
  }
 
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 });
 
293
  supabase.auth.onAuthStateChange(async (event, session) => {
294
  console.log("Auth state change:", event);
295
  if (session?.user) {
296
+ set({ user: session.user });
297
+ get().getProfile(session.user);
298
  } else {
299
  set({ user: null, profile: null });
300
  }
 
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
  }
313
  )
Frontend/src/store/ticketStore.js CHANGED
@@ -3,19 +3,14 @@ import { persist } from 'zustand/middleware';
3
 
4
  const useTicketStore = create(
5
  persist(
6
- (set, get) => ({
7
  aiTicket: null,
8
  activeTicket: null,
9
  autoResolvedTickets: [], // For analytics
10
  tickets: [], // Global queue for admins
11
  notifications: [], // User notifications
12
- wsConnected: false, // WebSocket connection status
13
-
14
  setAITicket: (data) => set({ aiTicket: data }),
15
  setActiveTicket: (ticket) => set({ activeTicket: ticket }),
16
-
17
- setWsConnected: (connected) => set({ wsConnected: connected }),
18
-
19
  addAutoResolvedTicket: (record) => set((state) => ({
20
  autoResolvedTickets: [...state.autoResolvedTickets, record]
21
  })),
@@ -35,68 +30,27 @@ const useTicketStore = create(
35
  tickets: [...state.tickets, ticket]
36
  };
37
  }),
38
- upsertTicket: (ticket) => set((state) => {
39
- const ticketId = ticket?.id ?? ticket?.ticket_id;
40
- if (!ticketId) return state;
 
 
 
 
41
 
42
- const exists = state.tickets.some(t => (t.id ?? t.ticket_id) === ticketId);
43
- const tickets = exists
44
- ? state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...ticket } : t)
45
- : [ticket, ...state.tickets];
46
- const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
47
 
48
- return {
49
- tickets,
50
- activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...ticket } : state.activeTicket
51
- };
52
- }),
53
- removeTicket: (ticketId) => set((state) => ({
54
- tickets: state.tickets.filter(t => (t.id ?? t.ticket_id) !== ticketId),
55
- activeTicket: (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId
56
- ? null
57
- : state.activeTicket
58
- })),
59
- updateTicket: (ticketId, updates) => set((state) => {
60
- const existingTicket = state.tickets.find(t => (t.id ?? t.ticket_id) === ticketId);
61
- if (!existingTicket) return state;
62
- const updatedTickets = state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...updates } : t);
63
- const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
64
  return {
65
  tickets: updatedTickets,
66
  activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
67
  };
68
  }),
69
 
70
- /**
71
- * Route an incoming WebSocket message to the correct store action.
72
- *
73
- * Call this from the component that owns the WebSocket connection
74
- * (e.g. AdminDashboard) whenever a message arrives.
75
- */
76
- handleWsMessage: (msg) => {
77
- if (!msg || !msg.type) return;
78
-
79
- const { type, event, ticket, ticket_id } = msg;
80
-
81
- switch (type) {
82
- case "ticket_update": {
83
- if (!ticket) break;
84
- if (event === "created") {
85
- // Avoid duplicates — use upsert
86
- get().upsertTicket(ticket);
87
- } else if (event === "updated") {
88
- get().upsertTicket(ticket);
89
- } else if (event === "deleted") {
90
- get().removeTicket(ticket_id);
91
- }
92
- break;
93
- }
94
- default:
95
- // Ignore unknown message types (e.g. heartbeat)
96
- break;
97
- }
98
- },
99
-
100
  appendMessage: (ticketId, message) => set((state) => {
101
  const updatedTickets = state.tickets.map(t =>
102
  t.ticket_id === ticketId
 
3
 
4
  const useTicketStore = create(
5
  persist(
6
+ (set) => ({
7
  aiTicket: null,
8
  activeTicket: null,
9
  autoResolvedTickets: [], // For analytics
10
  tickets: [], // Global queue for admins
11
  notifications: [], // User notifications
 
 
12
  setAITicket: (data) => set({ aiTicket: data }),
13
  setActiveTicket: (ticket) => set({ activeTicket: ticket }),
 
 
 
14
  addAutoResolvedTicket: (record) => set((state) => ({
15
  autoResolvedTickets: [...state.autoResolvedTickets, record]
16
  })),
 
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);
36
+ const updatedTickets = state.tickets.map(t => t.ticket_id === ticketId ? { ...t, ...updates } : t);
37
+ const shouldUpdateActive = state.activeTicket?.ticket_id === ticketId;
38
+
39
+
40
 
 
 
 
 
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  return {
43
  tickets: updatedTickets,
44
  activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
45
  };
46
  }),
47
 
48
+ removeTicket: (ticketId) => set((state) => ({
49
+ tickets: state.tickets.filter(t => t.ticket_id !== ticketId),
50
+ activeTicket: state.activeTicket?.ticket_id === ticketId
51
+ ? null
52
+ : state.activeTicket
53
+ })),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  appendMessage: (ticketId, message) => set((state) => {
55
  const updatedTickets = state.tickets.map(t =>
56
  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/App.js CHANGED
@@ -127,55 +127,17 @@ const AppContent = () => {
127
  const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
128
 
129
  useEffect(() => {
130
- let finished = false;
131
-
132
  const initialize = async () => {
133
- // Safety timeout wrapper: if anything hangs, force mount after 3 seconds
134
- const timeoutId = setTimeout(async () => {
135
- if (!finished) {
136
- console.log('[AuthInit] Timeout reached (3s). Forcing mount fallback.');
137
- try {
138
- const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
139
- setShowOnboarding(onboardingDone === null);
140
- } catch (err) {
141
- setShowOnboarding(false);
142
- }
143
- setLoading(false);
144
- }
145
- }, 3000);
146
-
147
  try {
148
- // 1. Instantly load cached status and role from AsyncStorage (stale-while-revalidate pattern)
149
- const [cachedStatus, cachedRole] = await Promise.all([
150
- AsyncStorage.getItem('@user_status'),
151
- AsyncStorage.getItem('@user_role'),
152
- ]);
153
-
154
- if (cachedStatus) setUserStatus(cachedStatus);
155
- if (cachedRole) setUserRole(cachedRole);
156
-
157
- // 2. Fetch session with a 2.5 second timeout wrapper to prevent slow refreshing from locking the app
158
- const sessionPromise = supabase.auth.getSession();
159
- const sessionTimeoutPromise = new Promise((_, reject) =>
160
- setTimeout(() => reject(new Error('Session fetch timed out')), 2500)
161
- );
162
-
163
- const { data: { session } } = await Promise.race([sessionPromise, sessionTimeoutPromise]);
164
  setSession(session);
165
 
166
- // 3. If session is valid, validate/fetch the user profile
167
  if (session?.user) {
168
- const profilePromise = supabase
169
  .from('profiles')
170
  .select('status, role')
171
  .eq('id', session.user.id)
172
  .single();
173
-
174
- const profileTimeoutPromise = new Promise((_, reject) =>
175
- setTimeout(() => reject(new Error('Profile fetch timed out')), 2000)
176
- );
177
-
178
- const { data, error } = await Promise.race([profilePromise, profileTimeoutPromise]);
179
 
180
  if (error) {
181
  console.log('[AuthInit] Profile fetch error, validating session:', error.message);
@@ -184,29 +146,20 @@ const AppContent = () => {
184
  if (userError) {
185
  console.log('[AuthInit] Token validation failed. Clearing session.');
186
  setSession(null);
187
- await AsyncStorage.removeItem('@user_status');
188
- await AsyncStorage.removeItem('@user_role');
189
  } else {
190
- // Valid token but profiles table is offline; retain cached or default to safe user status
191
- if (!cachedStatus) setUserStatus('active');
192
- if (!cachedRole) setUserRole('user');
193
  }
194
  } else {
195
- const status = data?.status || 'active';
196
- const role = data?.role || 'user';
197
- setUserStatus(status);
198
- setUserRole(role);
199
- await Promise.all([
200
- AsyncStorage.setItem('@user_status', status),
201
- AsyncStorage.setItem('@user_role', role),
202
- ]);
203
  }
204
  }
205
  } catch (e) {
206
- console.log('[AuthInit] Exception caught during initialization:', e.message || e);
207
  } finally {
208
- finished = true;
209
- clearTimeout(timeoutId);
210
  try {
211
  const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
212
  setShowOnboarding(onboardingDone === null);
@@ -231,17 +184,12 @@ const AppContent = () => {
231
 
232
  if (error) {
233
  console.log('[AuthChange] Profile query failed:', error.message);
 
234
  setUserStatus('active');
235
  setUserRole('user');
236
  } else {
237
- const status = data?.status || 'active';
238
- const role = data?.role || 'user';
239
- setUserStatus(status);
240
- setUserRole(role);
241
- await Promise.all([
242
- AsyncStorage.setItem('@user_status', status),
243
- AsyncStorage.setItem('@user_role', role),
244
- ]);
245
  }
246
  } catch (err) {
247
  console.warn('[AuthChange] Uncaught exception inside handler:', err);
@@ -251,10 +199,6 @@ const AppContent = () => {
251
  } else {
252
  setUserStatus(null);
253
  setUserRole('user');
254
- await Promise.all([
255
- AsyncStorage.removeItem('@user_status'),
256
- AsyncStorage.removeItem('@user_role'),
257
- ]);
258
  }
259
  });
260
 
@@ -272,15 +216,9 @@ const AppContent = () => {
272
  schema: 'public',
273
  table: 'profiles',
274
  filter: `id=eq.${session.user.id}`,
275
- }, async (payload) => {
276
- const status = payload.new.status;
277
- const role = payload.new.role || 'user';
278
- setUserStatus(status);
279
- setUserRole(role);
280
- await Promise.all([
281
- AsyncStorage.setItem('@user_status', status),
282
- AsyncStorage.setItem('@user_role', role),
283
- ]);
284
  })
285
  .subscribe();
286
 
@@ -291,6 +229,7 @@ const AppContent = () => {
291
  useEffect(() => {
292
  const handleUrl = async ({ url }) => {
293
  if (!url) return;
 
294
  const hashIndex = url.indexOf('#');
295
  if (hashIndex === -1) return;
296
  const hash = url.substring(hashIndex + 1);
@@ -316,7 +255,9 @@ const AppContent = () => {
316
  }
317
  };
318
 
 
319
  const subscription = Linking.addEventListener('url', handleUrl);
 
320
  Linking.getInitialURL().then(url => url && handleUrl({ url }));
321
 
322
  return () => subscription.remove();
 
127
  const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
128
 
129
  useEffect(() => {
 
 
130
  const initialize = async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  try {
132
+ const { data: { session } } = await supabase.auth.getSession();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  setSession(session);
134
 
 
135
  if (session?.user) {
136
+ const { data, error } = await supabase
137
  .from('profiles')
138
  .select('status, role')
139
  .eq('id', session.user.id)
140
  .single();
 
 
 
 
 
 
141
 
142
  if (error) {
143
  console.log('[AuthInit] Profile fetch error, validating session:', error.message);
 
146
  if (userError) {
147
  console.log('[AuthInit] Token validation failed. Clearing session.');
148
  setSession(null);
 
 
149
  } else {
150
+ // Valid token but profiles table is temporarily offline; default to user
151
+ setUserStatus('active');
152
+ setUserRole('user');
153
  }
154
  } else {
155
+ setUserStatus(data?.status || 'active');
156
+ setUserRole(data?.role || 'user');
 
 
 
 
 
 
157
  }
158
  }
159
  } catch (e) {
160
+ console.log('[AuthInit] Crash caught during initialization:', e);
161
  } finally {
162
+ // Guarantee showOnboarding is resolved to a boolean to prevent React Navigation stack layout mismatch
 
163
  try {
164
  const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
165
  setShowOnboarding(onboardingDone === null);
 
184
 
185
  if (error) {
186
  console.log('[AuthChange] Profile query failed:', error.message);
187
+ // Default to safe values to avoid blank screens
188
  setUserStatus('active');
189
  setUserRole('user');
190
  } else {
191
+ setUserStatus(data?.status || 'active');
192
+ setUserRole(data?.role || 'user');
 
 
 
 
 
 
193
  }
194
  } catch (err) {
195
  console.warn('[AuthChange] Uncaught exception inside handler:', err);
 
199
  } else {
200
  setUserStatus(null);
201
  setUserRole('user');
 
 
 
 
202
  }
203
  });
204
 
 
216
  schema: 'public',
217
  table: 'profiles',
218
  filter: `id=eq.${session.user.id}`,
219
+ }, (payload) => {
220
+ setUserStatus(payload.new.status);
221
+ setUserRole(payload.new.role || 'user');
 
 
 
 
 
 
222
  })
223
  .subscribe();
224
 
 
229
  useEffect(() => {
230
  const handleUrl = async ({ url }) => {
231
  if (!url) return;
232
+ // Parse hash fragment: helpdeskai://login#access_token=...&refresh_token=...
233
  const hashIndex = url.indexOf('#');
234
  if (hashIndex === -1) return;
235
  const hash = url.substring(hashIndex + 1);
 
255
  }
256
  };
257
 
258
+ // Handle app already open
259
  const subscription = Linking.addEventListener('url', handleUrl);
260
+ // Handle cold start — app launched from the link
261
  Linking.getInitialURL().then(url => url && handleUrl({ url }));
262
 
263
  return () => subscription.remove();
MobileApp/package-lock.json CHANGED
@@ -31,7 +31,7 @@
31
  "react-native-screens": "~4.16.0",
32
  "react-native-svg": "15.12.1",
33
  "react-native-url-polyfill": "^3.0.0",
34
- "react-native-webview": "^13.15.0",
35
  "zustand": "^5.0.12"
36
  },
37
  "devDependencies": {
 
31
  "react-native-screens": "~4.16.0",
32
  "react-native-svg": "15.12.1",
33
  "react-native-url-polyfill": "^3.0.0",
34
+ "react-native-webview": "^13.16.1",
35
  "zustand": "^5.0.12"
36
  },
37
  "devDependencies": {
MobileApp/package.json CHANGED
@@ -32,7 +32,7 @@
32
  "react-native-screens": "~4.16.0",
33
  "react-native-svg": "15.12.1",
34
  "react-native-url-polyfill": "^3.0.0",
35
- "react-native-webview": "^13.15.0",
36
  "zustand": "^5.0.12"
37
  },
38
  "private": true,
 
32
  "react-native-screens": "~4.16.0",
33
  "react-native-svg": "15.12.1",
34
  "react-native-url-polyfill": "^3.0.0",
35
+ "react-native-webview": "^13.16.1",
36
  "zustand": "^5.0.12"
37
  },
38
  "private": true,
MobileApp/src/lib/supabase.js CHANGED
@@ -2,14 +2,8 @@ import 'react-native-url-polyfill/auto';
2
  import { createClient } from '@supabase/supabase-js';
3
  import AsyncStorage from '@react-native-async-storage/async-storage';
4
 
5
- const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
6
- const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
7
-
8
- if (!supabaseUrl || !supabaseKey) {
9
- throw new Error(
10
- 'Missing Supabase mobile config. Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY in MobileApp/.env or EAS secrets.'
11
- );
12
- }
13
 
14
  export const supabase = createClient(supabaseUrl, supabaseKey, {
15
  auth: {
 
2
  import { createClient } from '@supabase/supabase-js';
3
  import AsyncStorage from '@react-native-async-storage/async-storage';
4
 
5
+ const supabaseUrl = 'https://aejuenhqciagpntcqoir.supabase.co';
6
+ const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFlanVlbmhxY2lhZ3BudGNxb2lyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIzODQwNzgsImV4cCI6MjA4Nzk2MDA3OH0.-OxgEW5t4alPGlzV_JZDRZcLsQbbMap6jiWjfAVkMMY';
 
 
 
 
 
 
7
 
8
  export const supabase = createClient(supabaseUrl, supabaseKey, {
9
  auth: {
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/auth/crypto.py CHANGED
@@ -1,218 +1,98 @@
1
  import os
2
- import base64
3
- import logging
4
  import hashlib
5
- from typing import Any
6
-
7
- # Configure logger
8
- logger = logging.getLogger(__name__)
9
- logger.setLevel(logging.WARNING)
10
-
11
- if not logger.handlers:
12
- handler = logging.StreamHandler()
13
- formatter = logging.Formatter("[Crypto] %(asctime)s - %(levelname)s - %(message)s")
14
- handler.setFormatter(formatter)
15
- logger.addHandler(handler)
16
-
17
- CRYPTOGRAPHY_AVAILABLE = False
18
- _aesgcm = None
19
- ENCRYPTION_ENABLED = False
20
-
21
- try:
22
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
23
- CRYPTOGRAPHY_AVAILABLE = True
24
- except ImportError:
25
- logger.warning("The 'cryptography' library is not available. Running with database encryption disabled.")
26
-
27
- # Key Parsing logic: supporting urlsafe-b64, hex, and SHA-256 stretching
28
- def derive_cryptographic_key(raw_key: str | None) -> bytes | None:
29
- if not raw_key:
30
- return None
31
-
32
- # 1. Try URL-safe Base64 decode
33
- try:
34
- # Pad string appropriately if needed
35
- padded = raw_key + "=" * ((4 - len(raw_key) % 4) % 4)
36
- decoded = base64.urlsafe_b64decode(padded.encode('utf-8'))
37
- if len(decoded) == 32:
38
- return decoded
39
- except Exception:
40
- pass
41
-
42
- # 2. Try hex decode
43
- try:
44
- decoded = bytes.fromhex(raw_key)
45
- if len(decoded) == 32:
46
- return decoded
47
- except Exception:
48
- pass
49
-
50
- # 3. Fall back to SHA-256 stretch
51
- return hashlib.sha256(raw_key.encode('utf-8')).digest()
52
-
53
-
54
- # Initialize Key and AESGCM instance
55
- if CRYPTOGRAPHY_AVAILABLE:
56
- SECRET_KEY_ENV_VAR = "DB_ENCRYPTION_SECRET_KEY"
57
- raw_secret_key = os.environ.get(SECRET_KEY_ENV_VAR)
58
-
59
- if raw_secret_key:
60
- try:
61
- key_bytes = derive_cryptographic_key(raw_secret_key)
62
- if key_bytes:
63
- _aesgcm = AESGCM(key_bytes)
64
- ENCRYPTION_ENABLED = True
65
- logger.info("Database encryption key loaded and active.")
66
- else:
67
- logger.warning("Could not derive key from DB_ENCRYPTION_SECRET_KEY. Database encryption disabled.")
68
- except Exception as e:
69
- logger.warning(f"Failed to initialize AESGCM: {e}. Database encryption disabled.")
70
- else:
71
- logger.warning("DB_ENCRYPTION_SECRET_KEY is not set in environment. Running with database encryption disabled.")
72
-
73
- # Tag prefix for identifying encrypted data
74
- PREFIX = "enc:v1:"
75
-
76
- def encrypt_value(value: str) -> str:
77
- """Encrypt a string value using AES-256-GCM. Returns 'enc:v1:<base64>'."""
78
- if not ENCRYPTION_ENABLED or _aesgcm is None:
79
- return value
80
- if not isinstance(value, str):
81
- return value
82
- # Double-encryption protection: if already encrypted, return as-is
83
- if value.startswith(PREFIX):
84
- return value
85
-
86
  try:
87
- # Generate 12-byte secure random nonce
88
  nonce = os.urandom(12)
89
- plaintext_bytes = value.encode('utf-8')
90
- # Encrypt (combines ciphertext and tag automatically in cryptography's AESGCM)
91
- ciphertext = _aesgcm.encrypt(nonce, plaintext_bytes, None)
92
- # Store nonce + ciphertext together
93
- payload = nonce + ciphertext
94
- encoded = base64.b64encode(payload).decode('utf-8')
95
- return f"{PREFIX}{encoded}"
96
  except Exception as e:
97
- logger.error(f"Encryption failed: {e}")
98
- return value
99
 
100
- def decrypt_value(value: str) -> str:
101
- """Decrypt a string value that starts with 'enc:v1:'."""
102
- if not ENCRYPTION_ENABLED or _aesgcm is None:
103
- return value
104
- if not isinstance(value, str):
105
- return value
106
- # Graceful pass-through for legacy/plaintext rows
107
- if not value.startswith(PREFIX):
108
- return value
109
-
110
  try:
111
- encoded_str = value[len(PREFIX):]
112
- payload = base64.b64decode(encoded_str)
113
- if len(payload) < 12:
114
- return value
115
- nonce = payload[:12]
116
- ciphertext = payload[12:]
117
- decrypted_bytes = _aesgcm.decrypt(nonce, ciphertext, None)
 
 
 
 
 
118
  return decrypted_bytes.decode('utf-8')
119
  except Exception as e:
120
- logger.error(f"Decryption failed: {e}")
121
- return value
122
 
123
- # ORM Payload Processing Helpers
124
- TARGET_FIELDS = {"contact_email", "description", "raw_text"}
125
-
126
- def encrypt_row(row: dict) -> dict:
127
- if not isinstance(row, dict):
128
- return row
129
- new_row = dict(row)
130
- for field in TARGET_FIELDS:
131
- if field in new_row and new_row[field] is not None:
132
- new_row[field] = encrypt_value(str(new_row[field]))
133
- return new_row
134
-
135
- def decrypt_row(row: dict) -> dict:
136
- if not isinstance(row, dict):
137
- return row
138
- new_row = dict(row)
139
- for field in TARGET_FIELDS:
140
- if field in new_row and new_row[field] is not None:
141
- new_row[field] = decrypt_value(str(new_row[field]))
142
- return new_row
143
-
144
- def encrypt_payload(payload: Any) -> Any:
145
- if isinstance(payload, list):
146
- return [encrypt_row(row) for row in payload]
147
- elif isinstance(payload, dict):
148
- return encrypt_row(payload)
149
- return payload
150
-
151
- def decrypt_payload(payload: Any) -> Any:
152
- if isinstance(payload, list):
153
- return [decrypt_row(row) for row in payload]
154
- elif isinstance(payload, dict):
155
- return decrypt_row(payload)
156
- return payload
157
-
158
- # Transparent client query wrapper proxy
159
- class WrappedRequestBuilder:
160
- def __init__(self, builder: Any, table_name: str):
161
- object.__setattr__(self, "_builder", builder)
162
- object.__setattr__(self, "_table_name", table_name)
163
-
164
- def insert(self, json: Any, *args, **kwargs) -> "WrappedRequestBuilder":
165
- if self._table_name == "tickets":
166
- json = encrypt_payload(json)
167
- res = self._builder.insert(json, *args, **kwargs)
168
- return WrappedRequestBuilder(res, self._table_name)
169
-
170
- def update(self, json: Any, *args, **kwargs) -> "WrappedRequestBuilder":
171
- if self._table_name == "tickets":
172
- json = encrypt_payload(json)
173
- res = self._builder.update(json, *args, **kwargs)
174
- return WrappedRequestBuilder(res, self._table_name)
175
-
176
- def execute(self, *args, **kwargs) -> Any:
177
- res = self._builder.execute(*args, **kwargs)
178
- if self._table_name == "tickets" and res and hasattr(res, "data"):
179
- res.data = decrypt_payload(res.data)
180
- return res
181
-
182
- def __getattr__(self, name: str) -> Any:
183
- attr = getattr(self._builder, name)
184
- if callable(attr):
185
- def wrapper(*args, **kwargs):
186
- res = attr(*args, **kwargs)
187
- if res is self._builder:
188
- return self
189
- if hasattr(res, "execute") or hasattr(res, "table") or hasattr(res, "insert"):
190
- return WrappedRequestBuilder(res, self._table_name)
191
- return res
192
- return wrapper
193
- return attr
194
-
195
- def __setattr__(self, name: str, value: Any) -> None:
196
- setattr(self._builder, name, value)
197
-
198
-
199
- def wrap_client(client: Any) -> Any:
200
- """Wraps a Supabase client's table method for transparent tickets encryption."""
201
- if client is None:
202
- return None
203
-
204
- # Avoid double wrapping
205
- if hasattr(client, "_wrapped_by_crypto"):
206
- return client
207
-
208
- original_table = client.table
209
-
210
- def wrapped_table(table_name: str, *args, **kwargs) -> Any:
211
- builder = original_table(table_name, *args, **kwargs)
212
- if table_name == "tickets":
213
- return WrappedRequestBuilder(builder, table_name)
214
- return builder
215
-
216
- client.table = wrapped_table
217
- client._wrapped_by_crypto = True
218
- return client
 
1
  import os
 
 
2
  import hashlib
3
+ import base64
4
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
5
+
6
+ # Initialize AESGCM with 32-byte key derived from secret key
7
+ SECRET_KEY = os.environ.get("DB_ENCRYPTION_SECRET_KEY")
8
+
9
+ _cipher = None
10
+ if SECRET_KEY:
11
+ # Hash secret key to ensure it is exactly 32 bytes (256 bits)
12
+ key_bytes = hashlib.sha256(SECRET_KEY.encode()).digest()
13
+ _cipher = AESGCM(key_bytes)
14
+ else:
15
+ print("[WARNING] DB_ENCRYPTION_SECRET_KEY not set. Data encryption is disabled (degraded mode).")
16
+
17
+ def encrypt(plain_text: str) -> str:
18
+ """Encrypt plain text using AES-256 GCM and return base64 encoded string."""
19
+ if not _cipher or not plain_text:
20
+ return plain_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  try:
22
+ # Generate 12-byte random nonce for GCM
23
  nonce = os.urandom(12)
24
+ encrypted_bytes = _cipher.encrypt(nonce, plain_text.encode(), None)
25
+ # Combine nonce and ciphertext and encode as base64
26
+ combined = nonce + encrypted_bytes
27
+ return base64.b64encode(combined).decode('utf-8')
 
 
 
28
  except Exception as e:
29
+ print(f"[Crypto Error] Encryption failed: {e}")
30
+ return plain_text
31
 
32
+ def decrypt(cipher_text: str) -> str:
33
+ """Decrypt base64 encoded ciphertext using AES-256 GCM and return plain text."""
34
+ if not _cipher or not cipher_text:
35
+ return cipher_text
 
 
 
 
 
 
36
  try:
37
+ # Check if cipher_text looks like base64
38
+ try:
39
+ combined = base64.b64decode(cipher_text.encode('utf-8'))
40
+ except Exception:
41
+ return cipher_text # Return as-is if not valid base64
42
+
43
+ if len(combined) < 12:
44
+ return cipher_text # Not enough bytes for nonce
45
+
46
+ nonce = combined[:12]
47
+ ciphertext = combined[12:]
48
+ decrypted_bytes = _cipher.decrypt(nonce, ciphertext, None)
49
  return decrypted_bytes.decode('utf-8')
50
  except Exception as e:
51
+ # If decryption fails, it might be unencrypted plaintext (graceful degrade for old records)
52
+ return cipher_text
53
 
54
+ def apply_db_encryption_patch():
55
+ """Apply transparent monkeypatch to Postgrest/Supabase client execute method for 'tickets' table."""
56
+ try:
57
+ from postgrest._sync.request_builder import SyncQueryRequestBuilder
58
+
59
+ _original_execute = SyncQueryRequestBuilder.execute
60
+
61
+ def custom_execute(self):
62
+ path_str = getattr(self.request.path, "path", "")
63
+ table_name = path_str.split('/')[-1]
64
+
65
+ if table_name == "tickets":
66
+ payload = self.request.json
67
+ if isinstance(payload, dict):
68
+ for field in ["contact_email", "description", "raw_text"]:
69
+ if field in payload and payload[field] is not None:
70
+ payload[field] = encrypt(str(payload[field]))
71
+ elif isinstance(payload, list):
72
+ for item in payload:
73
+ if isinstance(item, dict):
74
+ for field in ["contact_email", "description", "raw_text"]:
75
+ if field in item and item[field] is not None:
76
+ item[field] = encrypt(str(item[field]))
77
+
78
+ res = _original_execute(self)
79
+
80
+ if table_name == "tickets" and res and hasattr(res, "data"):
81
+ data = res.data
82
+ if isinstance(data, dict):
83
+ for field in ["contact_email", "description", "raw_text"]:
84
+ if field in data and data[field] is not None:
85
+ data[field] = decrypt(str(data[field]))
86
+ elif isinstance(data, list):
87
+ for item in data:
88
+ if isinstance(item, dict):
89
+ for field in ["contact_email", "description", "raw_text"]:
90
+ if field in item and item[field] is not None:
91
+ item[field] = decrypt(str(item[field]))
92
+
93
+ return res
94
+
95
+ SyncQueryRequestBuilder.execute = custom_execute
96
+ print("[Crypto] Supabase database encryption patch applied successfully.")
97
+ except Exception as e:
98
+ print(f"[Crypto WARNING] Failed to apply database encryption patch: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
@@ -20,13 +19,13 @@ from contextlib import asynccontextmanager
20
  warnings.filterwarnings("ignore", message="'pin_memory'")
21
 
22
  # HF Rebuild Trigger: 2026-03-08-2030
23
- from fastapi import FastAPI, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
24
  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, Response, StreamingResponse
29
- from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
30
  from fastapi.encoders import jsonable_encoder
31
  import asyncio
32
  from pathlib import Path
@@ -37,12 +36,13 @@ from dotenv import load_dotenv
37
  env_path = Path(__file__).parent / '.env'
38
  load_dotenv(dotenv_path=env_path)
39
 
40
- # CI smoke tests allow degraded startup so the app can import without heavy ML assets.
41
- ALLOW_DEGRADED_STARTUP = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
42
-
 
 
 
43
 
44
- def _startup_fatal(message: str) -> None:
45
- print(f"[Startup-FATAL] {message}")
46
 
47
  # Initialize Supabase Client (Service Role for backend bypass)
48
  try:
@@ -53,8 +53,7 @@ try:
53
  print("[ERROR] SUPABASE_URL or SUPABASE_SERVICE_KEY not set in backend/.env")
54
  supabase = None
55
  else:
56
- from backend.auth.crypto import wrap_client
57
- supabase = wrap_client(create_client(url, key))
58
  except (ImportError, Exception) as e:
59
  print(f"[WARNING] Supabase initialization failed: {e}")
60
  supabase = None
@@ -66,170 +65,17 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")
66
  from backend.services.classifier_service import ClassifierService
67
  from backend.services.classifier_v2 import classifier_v2
68
  from backend.services.classifier_v3 import classifier_v3 # V3 Power Model
69
- from backend.services.audit_service import AuditLogService, AuditLogAccessError
70
- from backend.services.onnx_service import onnx_classifier
71
  from backend.services.ner_service import NERService
72
  from backend.services.duplicate_service import DuplicateService
73
- from backend.services.semantic_duplicate_service import SemanticDuplicateService
74
  from backend.services.rag_service import RagService
75
- from backend.services.spam_service import SpamService
76
- from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
77
- from backend.services.redis_cache import redis_cache
78
- from backend.auth_cookie import router as auth_cookie_router, get_current_user # noqa: F401
79
-
80
-
81
- # ---------------------------------------------------------------------------
82
- # WebSocket Connection Manager — real-time ticket dashboards
83
- # ---------------------------------------------------------------------------
84
-
85
- HEARTBEAT_INTERVAL = 30 # seconds between ping broadcasts
86
- HEARTBEAT_TIMEOUT = 10 # seconds to wait for a pong before disconnect
87
-
88
-
89
- class ConnectionManager:
90
- """Tracks active WebSocket connections grouped by ``company_id``.
91
-
92
- Thread-safe for concurrent connect/disconnect calls from multiple
93
- ASGI workers (single-process via ``asyncio.Lock``).
94
- """
95
-
96
- def __init__(self) -> None:
97
- self._connections: dict[str, set[WebSocket]] = {}
98
- self._lock = asyncio.Lock()
99
-
100
- async def connect(self, company_id: str, ws: WebSocket) -> None:
101
- """Accept a new WebSocket and register it under ``company_id``."""
102
- await ws.accept()
103
- async with self._lock:
104
- self._connections.setdefault(company_id, set()).add(ws)
105
-
106
- async def disconnect(self, company_id: str, ws: WebSocket) -> None:
107
- """Remove a WebSocket from the pool."""
108
- async with self._lock:
109
- connections = self._connections.get(company_id)
110
- if connections:
111
- connections.discard(ws)
112
- # Clean up empty company groups
113
- if not connections:
114
- del self._connections[company_id]
115
-
116
- async def broadcast(self, company_id: str, message: dict) -> int:
117
- """Send a JSON message to every client in a company group.
118
-
119
- Returns:
120
- Number of successfully sent messages.
121
- """
122
- payload = json.dumps(message, default=str)
123
- sent = 0
124
- async with self._lock:
125
- connections = set(self._connections.get(company_id, []))
126
-
127
- for ws in connections:
128
- try:
129
- await ws.send_text(payload)
130
- sent += 1
131
- except Exception:
132
- await self.disconnect(company_id, ws)
133
- return sent
134
-
135
- async def broadcast_all(self, message: dict) -> int:
136
- """Send a JSON message to **all** connected clients."""
137
- payload = json.dumps(message, default=str)
138
- sent = 0
139
- async with self._lock:
140
- all_connections = {
141
- ws for group in self._connections.values() for ws in group
142
- }
143
-
144
- for ws in all_connections:
145
- try:
146
- await ws.send_text(payload)
147
- sent += 1
148
- except Exception:
149
- pass
150
- return sent
151
-
152
- async def ping_all(self) -> None:
153
- """Send a ``{"type": "ping"}`` heartbeat to every connection.
154
-
155
- Connections that fail to receive the ping are removed.
156
- """
157
- async with self._lock:
158
- # Snapshot all connections under lock so iteration is safe
159
- snapshot = {
160
- cid: set(ws_set) for cid, ws_set in self._connections.items()
161
- }
162
-
163
- for cid, ws_set in snapshot.items():
164
- for ws in list(ws_set):
165
- try:
166
- await ws.send_json({"type": "ping"})
167
- except Exception:
168
- await self.disconnect(cid, ws)
169
-
170
- @property
171
- def active_count(self) -> int:
172
- """Total number of connected clients across all companies."""
173
- return sum(len(ws_set) for ws_set in self._connections.values())
174
-
175
-
176
- # Singleton — reused across lifespan and WebSocket route
177
- connection_manager = ConnectionManager()
178
-
179
-
180
- async def _heartbeat_loop() -> None:
181
- """Background task: broadcast ping every ``HEARTBEAT_INTERVAL`` seconds.
182
-
183
- Clients that fail the ping are disconnected automatically by
184
- ``ConnectionManager.ping_all()``.
185
- """
186
- while True:
187
- await asyncio.sleep(HEARTBEAT_INTERVAL)
188
- try:
189
- await connection_manager.ping_all()
190
- count = connection_manager.active_count
191
- if count:
192
- print(f"[WS] Heartbeat sent to {count} active connection(s)")
193
- except Exception as exc:
194
- print(f"[WS] Heartbeat error: {exc}")
195
-
196
-
197
- # ---------------------------------------------------------------------------
198
- # SLA helper functions (must be defined before save_ticket uses them)
199
- # ---------------------------------------------------------------------------
200
-
201
- def calculate_sla_breach_at(priority: str) -> datetime.datetime:
202
- """Return the UTC datetime by which the ticket must be resolved."""
203
- hours_map = {"critical": 2, "high": 8, "medium": 24, "low": 72}
204
- hours = hours_map.get(str(priority).lower().strip(), 72)
205
- return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
206
-
207
-
208
- def calculate_sla_response_at(priority: str) -> datetime.datetime:
209
- """Return the UTC datetime by which the ticket must receive a first response."""
210
- hours_map = {"critical": 0.5, "high": 2, "medium": 6, "low": 18}
211
- hours = hours_map.get(str(priority).lower().strip(), 6)
212
- return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
213
-
214
-
215
- def classify_sla_status(sla_breach_at: str | None) -> str:
216
- """Return 'BREACHED', 'WARNING', or 'ACTIVE' based on the breach time."""
217
- if not sla_breach_at:
218
- return "ACTIVE"
219
- try:
220
- clean_val = str(sla_breach_at).replace("Z", "+00:00")
221
- deadline = datetime.datetime.fromisoformat(clean_val)
222
- if deadline.tzinfo is None:
223
- deadline = deadline.replace(tzinfo=datetime.timezone.utc)
224
- except Exception:
225
- return "ACTIVE"
226
-
227
- now = datetime.datetime.now(datetime.timezone.utc)
228
- if deadline <= now:
229
- return "BREACHED"
230
- if deadline - now <= datetime.timedelta(hours=1):
231
- return "WARNING"
232
- return "ACTIVE"
233
 
234
 
235
  # ---------------------------------------------------------------------------
@@ -278,62 +124,13 @@ def detect_semantic_duplicate(text: str, *, company_id: str | None, threshold: f
278
  duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
279
  duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
280
  return duplicate_result
281
-
282
-
283
- def classify_ticket_text(text: str) -> dict:
284
- """Run the local classifier cascade with ONNX as the offline fallback path."""
285
- cached = redis_cache.get_classification(text)
286
- if cached:
287
- return cached
288
-
289
- result = _classify_ticket_text_uncached(text)
290
- redis_cache.set_classification(text, result)
291
- return result
292
-
293
-
294
- def _classify_ticket_text_uncached(text: str) -> dict:
295
- try:
296
- classification_v3_res = classifier_v3.predict(text)
297
- if "error" not in classification_v3_res:
298
- cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
299
- sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
300
- pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
301
- conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
302
-
303
- from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
304
- return {
305
- "category": cat,
306
- "subcategory": sub,
307
- "priority": pri,
308
- "auto_resolve": sub in AUTO_RESOLVE_SUBS,
309
- "assigned_team": TEAM_MAP.get(cat, "General Support"),
310
- "confidence": float(conf),
311
- }
312
- except Exception:
313
- traceback.print_exc()
314
-
315
- try:
316
- onnx_result = onnx_classifier.predict(text)
317
- if onnx_result:
318
- return onnx_result
319
- except Exception as error:
320
- print(f"[ONNX] Fallback classification skipped: {error}")
321
-
322
- try:
323
- return classifier_service.predict(text)
324
- except Exception:
325
- traceback.print_exc()
326
- return {
327
- "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
328
- "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
329
- }
330
-
331
  class TicketRequest(BaseModel):
332
  text: str
333
  image_base64: str = ""
334
  image_text: str = "" # Keep for backward compatibility
335
  user_id: str | None = None
336
  company: str | None = None
 
337
  image_url: str | None = None
338
  confidence_threshold: float = 0.20
339
  duplicate_sensitivity: float = 0.85
@@ -353,6 +150,10 @@ class TicketSaveRequest(BaseModel):
353
  image_url: str | None = None
354
  company: str | None = None
355
  company_id: str | None = None
 
 
 
 
356
  sla_breach_at: str
357
  sla_status: str | None = None
358
  escalation_level: int = 0
@@ -368,6 +169,8 @@ class TicketSaveRequest(BaseModel):
368
  class DuplicateInfo(BaseModel):
369
  is_duplicate: bool
370
  duplicate_ticket_id: str | None = None
 
 
371
  similarity: float = 0.0
372
 
373
 
@@ -377,14 +180,6 @@ class EntityInfo(BaseModel):
377
  confidence: float
378
 
379
 
380
- class SpamCheck(BaseModel):
381
- is_spam: bool = False
382
- risk_score: float = 0.0
383
- reasons: list[str] = []
384
- suspicious_urls: list[str] = []
385
- matched_keywords: list[str] = []
386
-
387
-
388
  class TicketResponse(BaseModel):
389
  id: str | int | None = None
390
  ticket_id: str | None = None
@@ -397,6 +192,8 @@ class TicketResponse(BaseModel):
397
  entities: list[EntityInfo]
398
  duplicate_ticket: DuplicateInfo
399
  confidence: float
 
 
400
  needs_review: bool = False
401
  reasoning: str = ""
402
  decision_factors: list[str] = []
@@ -406,11 +203,7 @@ class TicketResponse(BaseModel):
406
  timeline: dict = {} # Map of step_name: timestamp
407
  env_metadata: dict = {} # IP, Hostname, Browser/OS
408
  sla_breach_at: str | None = None
409
- original_text: str | None = None
410
- source_language: str = "en"
411
- source_language_name: str = "English"
412
- was_translated: bool = False
413
- spam_check: SpamCheck = SpamCheck()
414
  version: str = "2.1.0-Neural-Diagnostic"
415
 
416
 
@@ -438,24 +231,6 @@ class TicketRecord(BaseModel):
438
  timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
439
 
440
 
441
- class AuditLogProfile(BaseModel):
442
- full_name: str | None = None
443
- email: str | None = None
444
- profile_picture: str | None = None
445
-
446
-
447
- class AuditLogRecord(BaseModel):
448
- id: str
449
- ticket_id: str
450
- company_id: str
451
- performed_by: str | None = None
452
- action: str
453
- old_value: dict | list | str | None = None
454
- new_value: dict | list | str | None = None
455
- created_at: str
456
- performed_by_profile: AuditLogProfile | None = None
457
-
458
-
459
  # --- In-Memory Database (to be replaced with SQL later) ---
460
  TICKETS_DB: list[TicketRecord] = []
461
 
@@ -478,9 +253,6 @@ classifier_service = ClassifierService()
478
  ner_service = NERService()
479
  duplicate_service = DuplicateService()
480
  rag_service = RagService()
481
- spam_service = SpamService()
482
- sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
483
- semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
484
 
485
  try:
486
  from backend.services.gemini_service import GeminiService
@@ -494,82 +266,6 @@ try:
494
  except ImportError:
495
  ocr_service = None
496
 
497
- LANGUAGE_NAMES = {
498
- "en": "English",
499
- "es": "Spanish",
500
- "de": "German",
501
- "hi": "Hindi",
502
- "fr": "French",
503
- "it": "Italian",
504
- "pt": "Portuguese",
505
- "ja": "Japanese",
506
- "ko": "Korean",
507
- "zh": "Chinese",
508
- "ar": "Arabic",
509
- "ru": "Russian",
510
- }
511
-
512
- def _heuristic_language_detection(text: str) -> dict:
513
- sample = (text or "").strip()
514
- if not sample:
515
- return {"code": "en", "name": "English"}
516
- ascii_chars = sum(1 for c in sample if ord(c) < 128)
517
- ratio = ascii_chars / max(len(sample), 1)
518
- if ratio > 0.97:
519
- return {"code": "en", "name": "English"}
520
- return {"code": "unknown", "name": "Unknown"}
521
-
522
- def detect_and_translate_ticket_text(text: str) -> dict:
523
- original_text = (text or "").strip()
524
- if not original_text:
525
- return {
526
- "text_for_analysis": text or "",
527
- "source_language": "en",
528
- "source_language_name": "English",
529
- "was_translated": False,
530
- "original_text": "",
531
- "metadata":{},
532
- }
533
-
534
- detected = _heuristic_language_detection(original_text)
535
- if gemini_service and getattr(gemini_service, "_initialized", False):
536
- detected = gemini_service.detect_language(original_text)
537
-
538
- source_code = str(detected.get("code", "en")).lower()
539
- source_name = detected.get("name") or LANGUAGE_NAMES.get(source_code, source_code.upper())
540
- if source_code in ("en", "eng"):
541
- return {
542
- "text_for_analysis": original_text,
543
- "source_language": "en",
544
- "source_language_name": "English",
545
- "was_translated": False,
546
- "original_text": original_text,
547
- "metadata":{},
548
- }
549
-
550
- translated_text = original_text
551
- if gemini_service and getattr(gemini_service, "_initialized", False):
552
- translated_text = gemini_service.translate_to_english(original_text, source_name)
553
-
554
- if not translated_text or translated_text.strip() == original_text:
555
- return {
556
- "text_for_analysis": original_text,
557
- "source_language": source_code,
558
- "source_language_name": source_name,
559
- "was_translated": False,
560
- "original_text": original_text,
561
- "metadata":{},
562
- }
563
-
564
- return {
565
- "text_for_analysis": translated_text.strip(),
566
- "source_language": source_code,
567
- "source_language_name": source_name,
568
- "was_translated": True,
569
- "original_text": original_text,
570
- "metadata":{},
571
- }
572
-
573
 
574
  # ---------------------------------------------------------------------------
575
  # Lifespan (startup / shutdown)
@@ -578,17 +274,13 @@ def detect_and_translate_ticket_text(text: str) -> dict:
578
  async def lifespan(app: FastAPI):
579
  """Load all models at startup."""
580
  print("[Startup] Loading AI models ...")
581
- try:
582
- redis_cache.connect()
583
- except Exception as e:
584
- print(f"[WARNING] Redis cache not available: {e}")
585
  try:
586
  classifier_service.load()
587
- except FileNotFoundError as e:
588
  print(f"[WARNING] Classifier not loaded: {e}")
589
  try:
590
  ner_service.load()
591
- except FileNotFoundError as e:
592
  print(f"[WARNING] NER not loaded: {e}")
593
  try:
594
  duplicate_service.load()
@@ -598,51 +290,50 @@ async def lifespan(app: FastAPI):
598
  rag_service.load()
599
  except Exception as e:
600
  print(f"[WARNING] RAG service not loaded: {e}")
601
- try:
602
- onnx_classifier.load()
603
- except Exception as e:
604
- print(f"[WARNING] ONNX classifier fallback not loaded: {e}")
605
 
606
  if gemini_service:
607
  print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
608
  else:
609
  print("[Startup] Gemini Service: NOT LOADED (Import failed)")
610
 
611
- # Wire services with supabase client
612
- sla_engine.supabase = supabase
613
- semantic_dupe_service.supabase = supabase
614
-
615
- # Pre-load embedding model so first ticket save is fast
616
- try:
617
- semantic_dupe_service.load()
618
- print(f"[Startup] Semantic Duplicate Detection: {'Loaded' if semantic_dupe_service._loaded else 'Failed (model missing)'}")
619
- except Exception as e:
620
- print(f"[Startup] Semantic Duplicate Detection load error: {e}")
621
- print(f"[Startup] SLA Engine: {'Initialized' if supabase else 'Offline (no DB)'}")
622
-
623
- # Start background SLA checker as an async task (every 5 minutes)
624
- if supabase:
625
- from backend.sla_checker import sla_checker_loop_async
626
- asyncio.create_task(sla_checker_loop_async(supabase, interval_seconds=300))
627
- print("[Startup] SLA background checker started (interval=300s)")
628
-
629
  print("[Startup] Classifier V2 Shadow: Ready.")
630
- print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
631
  print("[Startup] Ready.")
 
 
 
 
 
 
632
 
633
- # Start WebSocket heartbeat background loop
634
- heartbeat_task = asyncio.create_task(_heartbeat_loop())
635
- print("[Startup] WebSocket heartbeat loop started (interval=30s).")
636
 
637
- yield
 
638
 
639
- # Cancel background tasks on shutdown
640
- heartbeat_task.cancel()
641
  try:
642
- await heartbeat_task
643
- except asyncio.CancelledError:
644
- pass
645
- print("[Shutdown] Cleaning up ...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
 
647
 
648
  # ---------------------------------------------------------------------------
@@ -673,8 +364,6 @@ app.add_middleware(
673
  allow_headers=["*"],
674
  )
675
 
676
- app.include_router(auth_cookie_router)
677
-
678
 
679
  # ---------------------------------------------------------------------------
680
  # Root & Health check
@@ -761,6 +450,17 @@ async def root():
761
  """
762
 
763
 
 
 
 
 
 
 
 
 
 
 
 
764
  @app.get("/health", response_model=HealthResponse)
765
  async def health_check():
766
  return HealthResponse(
@@ -773,18 +473,29 @@ async def health_check():
773
  @app.get("/ready", response_model=ReadinessResponse)
774
  async def readiness_check():
775
  require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
 
 
776
  checks = {
777
  "api": True,
778
  "classifier_loaded": classifier_service._loaded,
779
  "ner_loaded": ner_service._loaded,
780
- "duplicate_index_loaded": duplicate_service._loaded,
781
- "rag_loaded": rag_service._loaded,
782
  }
783
  if require_supabase:
784
  checks["supabase_configured"] = supabase is not None
785
 
786
- if all(checks.values()):
787
- return ReadinessResponse(status="ready", checks=checks)
 
 
 
 
 
 
 
 
 
788
 
789
  return JSONResponse(
790
  status_code=503,
@@ -912,66 +623,44 @@ async def log_correction(raw_request: Request):
912
  # ---------------------------------------------------------------------------
913
  # Ticket operations (Now via Supabase)
914
  # ---------------------------------------------------------------------------
915
- MASTER_TICKET_ROLES = {"master_admin", "super_admin", "superadmin", "owner"}
916
-
917
-
918
- def _get_auth_user_id(user: dict) -> str:
919
- user_id = user.get("id") or user.get("sub") or user.get("user_id")
920
- if not user_id:
921
- raise HTTPException(status_code=401, detail="Invalid authenticated user")
922
- return str(user_id)
923
-
924
-
925
- def _get_authenticated_profile(user: dict) -> dict:
926
- user_id = _get_auth_user_id(user)
927
- res = (
928
- supabase.table("profiles")
929
- .select("id, company_id, company, role")
930
- .eq("id", user_id)
931
- .single()
932
- .execute()
933
- )
934
- if not res.data:
935
- raise HTTPException(status_code=403, detail="User profile not found")
936
- return res.data
937
-
938
-
939
- def _is_master_ticket_reader(profile: dict) -> bool:
940
- role = str(profile.get("role") or "").lower()
941
- return role in MASTER_TICKET_ROLES
942
-
943
-
944
- def _ticket_company_scope(profile: dict, requested_company_id: str | None = None) -> str | None:
945
- if _is_master_ticket_reader(profile):
946
- return requested_company_id
947
-
948
- company_id = profile.get("company_id")
949
- if not company_id:
950
- raise HTTPException(status_code=403, detail="User tenant is not configured")
951
- if requested_company_id and requested_company_id != company_id:
952
- raise HTTPException(status_code=403, detail="User not authorized for this tenant")
953
- return str(company_id)
954
-
955
-
956
  @app.get("/tickets")
957
- async def get_tickets(
958
- company_id: str | None = None,
959
- current_user: dict = Depends(get_current_user),
960
- ):
961
  """Fetch persistent tickets from Supabase."""
962
  if not supabase:
963
  raise HTTPException(status_code=500, detail="Database connection not initialized")
964
-
965
- profile = _get_authenticated_profile(current_user)
966
- company_scope = _ticket_company_scope(profile, company_id)
967
 
968
  query = supabase.table("tickets").select("*").order("created_at", desc=True)
969
- if company_scope:
970
- query = query.eq("company_id", company_scope)
971
 
972
  res = query.execute()
973
  return res.data
974
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
975
  @app.post("/tickets/save")
976
  async def save_ticket(request_body: TicketSaveRequest):
977
  """
@@ -982,80 +671,64 @@ async def save_ticket(request_body: TicketSaveRequest):
982
  raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
983
 
984
  logger = logging.getLogger(__name__)
985
- final_data = request_body.model_dump()
986
- original_subject = final_data.get("subject", "") or ""
987
- original_description = final_data.get("description", "") or ""
988
-
989
- # Detect language and translate subject/description into English before downstream routing/indexing.
990
- translation_probe_text = (original_description.strip() or original_subject.strip())
991
- translation_ctx = detect_and_translate_ticket_text(translation_probe_text)
992
- metadata = final_data.get("metadata") or {}
993
- if translation_ctx["was_translated"]:
994
- translated_subject = gemini_service.translate_to_english(original_subject, translation_ctx["source_language_name"]) if original_subject else original_subject
995
- translated_description = gemini_service.translate_to_english(original_description, translation_ctx["source_language_name"]) if original_description else original_description
996
- final_data["subject"] = translated_subject or original_subject
997
- final_data["description"] = translated_description or original_description
998
- metadata["original_text"] = {
999
- "subject": original_subject,
1000
- "description": original_description,
1001
- }
1002
- metadata["translation"] = {
1003
- "translated": bool(translation_ctx["was_translated"]),
1004
- "source_language": translation_ctx["source_language"],
1005
- "source_language_name": translation_ctx["source_language_name"],
1006
- }
1007
- final_data["metadata"] = metadata
1008
-
1009
- # Backfill SLA deadlines/status when the client omits or sends empty values.
1010
- priority_key = str(final_data.get("priority") or "medium").lower().strip()
1011
- now_utc = datetime.datetime.now(datetime.timezone.utc)
1012
-
1013
- if not str(final_data.get("sla_breach_at") or "").strip():
1014
- final_data["sla_breach_at"] = compute_sla_breach_at(priority_key, now_utc)
1015
-
1016
- if not str(final_data.get("sla_response_due_at") or "").strip():
1017
- policy = get_sla_policy(priority_key)
1018
- response_hours = max(1, int(round(float(policy["max_hours"]) * 0.25)))
1019
- response_due_at = now_utc + datetime.timedelta(hours=response_hours)
1020
- final_data["sla_response_due_at"] = response_due_at.isoformat()
1021
-
1022
- if not str(final_data.get("sla_status") or "").strip():
1023
- final_data["sla_status"] = "ACTIVE"
1024
- # Resolve tenant linkage from user profile with authorization validation.
1025
- profile = {}
1026
- if request_body.user_id:
1027
- try:
1028
- profile_res = (
1029
- supabase.table("profiles")
1030
- .select("company_id, company")
1031
- .eq("id", request_body.user_id)
1032
- .single()
1033
- .execute()
1034
- )
1035
- profile = profile_res.data or {}
1036
- if not profile:
1037
- raise HTTPException(status_code=404, detail="User profile not found")
1038
- except HTTPException:
1039
- raise
1040
- except Exception as profile_error:
1041
- logger.error(f"Tenant resolution error for user {request_body.user_id}: {profile_error}")
1042
- raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
1043
-
1044
- # Validate tenant consistency and authorization.
1045
- profile_company_id = profile.get("company_id")
1046
- if final_data.get("company_id"):
1047
- # User provided company_id: verify it matches their profile.
1048
- if profile_company_id and final_data["company_id"] != profile_company_id:
1049
- logger.warning(f"Tenant mismatch: user {request_body.user_id} attempted {final_data['company_id']}, assigned to {profile_company_id}")
1050
- raise HTTPException(status_code=403, detail="User not authorized for this tenant")
1051
- elif profile_company_id:
1052
- # Backfill company_id from profile.
1053
- final_data["company_id"] = profile_company_id
1054
- elif request_body.user_id:
1055
- # User has no tenant assignment.
1056
- raise HTTPException(status_code=400, detail="User has no tenant assignment")
1057
-
1058
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1059
  # Backfill company name if missing.
1060
  if not final_data.get("company") and profile.get("company"):
1061
  final_data["company"] = profile["company"]
@@ -1068,31 +741,32 @@ async def save_ticket(request_body: TicketSaveRequest):
1068
  final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
1069
  final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
1070
 
 
1071
  user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
1072
  logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
1073
 
1074
  duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
1075
- duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85) # noqa: F841
 
 
 
 
 
 
 
1076
 
 
 
 
 
 
 
 
 
 
1077
 
1078
- # Semantic duplicate check BEFORE inserting the ticket
1079
- # This allows us to warn the user before confirming
1080
- duplicate_check_result = None
1081
- try:
1082
- dupe_text = (request_body.description or request_body.subject or "").strip()
1083
- if dupe_text:
1084
- duplicate_check_result = await semantic_dupe_service.check_duplicate(
1085
- text=dupe_text,
1086
- company_id=final_data.get("company_id"),
1087
- )
1088
- if duplicate_check_result["is_duplicate"]:
1089
- logger.info(
1090
- f"[DUPLICATE] Ticket flagged as potential duplicate of "
1091
- f"{duplicate_check_result['duplicate_ticket_id']} "
1092
- f"(similarity: {duplicate_check_result['similarity']})"
1093
- )
1094
- except Exception as e:
1095
- logger.warning(f"[DUPLICATE] Semantic check error (non-fatal): {e}")
1096
 
1097
  # --- Sanitize payload to only include valid Supabase DB columns ---
1098
  # Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
@@ -1100,14 +774,14 @@ async def save_ticket(request_body: TicketSaveRequest):
1100
  VALID_TICKET_COLUMNS = {
1101
  "user_id", "subject", "description", "category", "subcategory",
1102
  "priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
1103
- "confidence", "image_url", "company", "company_id",
1104
- "sla_breach_at", "sla_response_due_at", "sla_status", "escalation_level", "metadata",
1105
  }
1106
  # Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
1107
  existing_metadata = final_data.get("metadata") or {}
1108
  extra_keys = (
1109
  "entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
1110
- "is_potential_duplicate", "parent_ticket_id"
 
1111
  )
1112
  for extra_key in extra_keys:
1113
  if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
@@ -1124,29 +798,19 @@ async def save_ticket(request_body: TicketSaveRequest):
1124
 
1125
  ticket_id = res.data[0]["id"]
1126
 
1127
- # If duplicate detected, link parent ticket
1128
- if duplicate_check_result and duplicate_check_result["is_duplicate"]:
1129
- try:
1130
- supabase.table("tickets").update({
1131
- "is_potential_duplicate": True,
1132
- "parent_ticket_id": duplicate_check_result["duplicate_ticket_id"],
1133
- }).eq("id", ticket_id).execute()
1134
- except Exception as e:
1135
- logger.warning(f"[DUPLICATE] Failed to link parent ticket: {e}")
1136
-
1137
- # Index the new ticket's embedding for future duplicate checks
1138
- embedding_indexed = False
1139
- description_text = (request_body.description or "").strip()
1140
- subject_text = (request_body.subject or "").strip()
1141
- duplicate_text = description_text or subject_text
1142
  if duplicate_text:
1143
  try:
1144
- # Both: old in-memory index (for backward compat) and new pgvector index
1145
  duplicate_service.add_ticket(str(ticket_id), duplicate_text)
1146
- asyncio.create_task(semantic_dupe_service.index_ticket(ticket_id, duplicate_text))
1147
- embedding_indexed = True
1148
  except Exception as index_error:
1149
- logger.warning(f"[INDEX] Failed to index ticket {ticket_id}: {index_error}")
 
 
 
 
 
 
1150
 
1151
  # Add initial system diagnostic message
1152
  msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
@@ -1164,157 +828,30 @@ async def save_ticket(request_body: TicketSaveRequest):
1164
  response = {
1165
  "status": "success",
1166
  "ticket_id": ticket_id,
1167
- "duplicate_indexed": embedding_indexed,
 
 
1168
  }
1169
- if duplicate_check_result and duplicate_check_result["is_duplicate"]:
1170
- response["duplicate_warning"] = True
1171
- response["parent_ticket_id"] = duplicate_check_result["duplicate_ticket_id"]
1172
- response["parent_subject"] = duplicate_check_result.get("parent_subject")
1173
- response["similarity"] = duplicate_check_result["similarity"]
1174
- response["candidates"] = duplicate_check_result.get("candidates", [])
1175
-
1176
- # Broadcast the new/updated ticket to all WebSocket clients for this company
1177
- company_id = final_data.get("company_id")
1178
- if company_id:
1179
- asyncio.create_task(
1180
- connection_manager.broadcast(
1181
- company_id,
1182
- {
1183
- "type": "ticket_update",
1184
- "event": "created",
1185
- "ticket": insert_data,
1186
- "ticket_id": str(ticket_id),
1187
- },
1188
- )
1189
- )
1190
  return response
1191
 
1192
  except Exception as e:
1193
  traceback.print_exc()
1194
  raise HTTPException(status_code=500, detail=str(e))
1195
 
1196
- @app.websocket("/ws/{company_id}")
1197
- async def websocket_endpoint(ws: WebSocket, company_id: str):
1198
- """Real-time WebSocket feed for a company's ticket dashboard.
1199
-
1200
- Protocol:
1201
- - Server sends ``{"type": "ping"}`` every 30s (heartbeat).
1202
- - Client must respond with ``{"type": "pong"}`` within 10s.
1203
- - Server pushes ``{"type": "ticket_update", ...}`` on changes.
1204
-
1205
- Usage (frontend):
1206
- const socket = new WebSocket("ws://host:7860/ws/{company_id}");
1207
- socket.onmessage = (event) => { const msg = JSON.parse(event.data); };
1208
- """
1209
- if not company_id or not company_id.strip():
1210
- await ws.close(code=4000, reason="Missing company_id")
1211
- return
1212
-
1213
- company_id = company_id.strip()
1214
- await connection_manager.connect(company_id, ws)
1215
- print(f"[WS] Client connected — company_id={company_id}")
1216
-
1217
- try:
1218
- while True:
1219
- raw = await ws.receive_text()
1220
- if not raw.strip():
1221
- continue
1222
- try:
1223
- data = json.loads(raw)
1224
- except json.JSONDecodeError:
1225
- continue # ignore malformed frames
1226
-
1227
- # Handle pong response
1228
- if data.get("type") == "pong":
1229
- continue
1230
-
1231
- except WebSocketDisconnect:
1232
- pass
1233
- except Exception as exc:
1234
- print(f"[WS] Connection error for company_id={company_id}: {exc}")
1235
- finally:
1236
- await connection_manager.disconnect(company_id, ws)
1237
- print(f"[WS] Client disconnected — company_id={company_id}")
1238
-
1239
-
1240
  @app.get("/tickets/{ticket_id}")
1241
- async def get_ticket_by_id(
1242
- request: Request,
1243
- ticket_id: str,
1244
- current_user: dict = Depends(get_current_user),
1245
- ):
1246
  """Fetch single persistent ticket."""
1247
  if not supabase:
1248
  raise HTTPException(status_code=500, detail="Database connection not initialized")
1249
-
1250
- # Guard route overlap where '/tickets/search' may be matched here first.
1251
- if ticket_id == "search":
1252
- return await search_tickets(
1253
- q=request.query_params.get("q", ""),
1254
- company_id=request.query_params.get("company_id"),
1255
- current_user=current_user,
1256
- )
1257
-
1258
- profile = _get_authenticated_profile(current_user)
1259
- company_scope = _ticket_company_scope(profile)
1260
  res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
1261
  if not res.data:
1262
  raise HTTPException(status_code=404, detail="Ticket not found")
1263
- if company_scope and res.data.get("company_id") != company_scope:
1264
- raise HTTPException(status_code=403, detail="User not authorized for this tenant")
1265
  return res.data
1266
 
1267
 
1268
- @app.get("/tickets/{ticket_id}/audit_logs", response_model=list[AuditLogRecord])
1269
- async def get_ticket_audit_logs(ticket_id: str, company_id: str):
1270
- """Return a company-scoped chronological audit trail for a ticket."""
1271
- if not supabase:
1272
- raise HTTPException(status_code=500, detail="Database connection not initialized")
1273
-
1274
- try:
1275
- service = AuditLogService(supabase)
1276
- return service.get_ticket_audit_logs(ticket_id, company_id)
1277
- except AuditLogAccessError as err:
1278
- raise HTTPException(status_code=err.status_code, detail=err.detail)
1279
-
1280
-
1281
- @app.get("/tickets/search")
1282
- async def search_tickets(
1283
- q: str,
1284
- company_id: str | None = None,
1285
- current_user: dict = Depends(get_current_user),
1286
- ):
1287
- """Search tickets by query text, optionally scoped by company_id."""
1288
- if not supabase:
1289
- raise HTTPException(status_code=500, detail="Database connection not initialized")
1290
- query_text = (q or "").strip()
1291
- if not query_text:
1292
- raise HTTPException(status_code=400, detail="Query text is required")
1293
-
1294
- profile = _get_authenticated_profile(current_user)
1295
- company_scope = _ticket_company_scope(profile, company_id)
1296
-
1297
- try:
1298
- rpc_res = supabase.rpc(
1299
- "search_tickets",
1300
- {"query_text": query_text, "company_id": company_scope},
1301
- ).execute()
1302
- return rpc_res.data or []
1303
- except Exception:
1304
- # Fallback for environments without RPC function support.
1305
- fallback = supabase.table("tickets").select("*").order("created_at", desc=True).execute()
1306
- rows = fallback.data or []
1307
- lowered = query_text.lower()
1308
- filtered = [
1309
- row for row in rows
1310
- if lowered in str(row.get("subject", "")).lower()
1311
- or lowered in str(row.get("description", "")).lower()
1312
- ]
1313
- if company_scope:
1314
- filtered = [row for row in filtered if row.get("company_id") == company_scope]
1315
- return filtered
1316
-
1317
-
1318
  @app.post("/tickets", response_model=TicketRecord)
1319
  async def create_ticket(ticket: TicketRecord):
1320
  """Save a new ticket into the system."""
@@ -1353,6 +890,11 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
1353
  Main endpoint for analyzing a new ticket using the cascade of local AI models.
1354
  """
1355
  text = request_body.text
 
 
 
 
 
1356
 
1357
  # Grab client metadata
1358
  client_ip = request.client.host if request.client else "unknown"
@@ -1374,9 +916,8 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
1374
  text = f"{text} {local_ocr_text}".strip()
1375
  print(f"[AI] OCR added {len(local_ocr_text)} chars to context.")
1376
 
1377
- # Pass OCR-enriched text downstream so the analyze_only endpoint uses it.
1378
- enriched = request_body.model_copy(update={"text": text, "image_text": local_ocr_text})
1379
- return await analyze_only(enriched)
1380
 
1381
  @app.post("/ai/analyze")
1382
  async def analyze_only(request_body: TicketRequest):
@@ -1386,8 +927,6 @@ async def analyze_only(request_body: TicketRequest):
1386
  and duplicate check before committing to a ticket creation.
1387
  """
1388
  text = request_body.text
1389
- translation_ctx = detect_and_translate_ticket_text(text)
1390
- text = translation_ctx["text_for_analysis"]
1391
  print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
1392
  settings = get_system_settings(request_body.company)
1393
  confidence_threshold = settings["ai_confidence_threshold"]
@@ -1429,11 +968,9 @@ async def analyze_only(request_body: TicketRequest):
1429
  highlights=[],
1430
  timeline={"received": _dt.datetime.utcnow().isoformat() + "Z"},
1431
  env_metadata={},
 
 
1432
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
1433
- original_text=request_body.text,
1434
- source_language=translation_ctx["source_language"],
1435
- source_language_name=translation_ctx["source_language_name"],
1436
- was_translated=translation_ctx["was_translated"],
1437
  )
1438
 
1439
  # --- Context & Environment ---
@@ -1465,18 +1002,54 @@ async def analyze_only(request_body: TicketRequest):
1465
 
1466
  summary = text[:100] + ("…" if len(text) > 100 else "")
1467
 
1468
- # --- Spam / Phishing Detection (runs before classification) ---
 
 
 
1469
  try:
1470
- spam_result = spam_service.check(text, gemini_analysis.get("ocr_text", ""))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1471
  except Exception as e:
1472
- print(f"[SPAM ERROR] {e}")
1473
- spam_result = {
1474
- "is_spam": False, "risk_score": 0.0, "reasons": [],
1475
- "suspicious_urls": [], "matched_keywords": [],
1476
  }
1477
 
1478
- # --- Classification ---
1479
- classification = classify_ticket_text(text)
 
 
 
 
 
 
 
 
 
 
 
1480
 
1481
  timeline["ai_analyzed"] = get_now_ist()
1482
  timeline["triaged"] = get_now_ist()
@@ -1490,10 +1063,21 @@ async def analyze_only(request_body: TicketRequest):
1490
  timeline["metadata_harvested"] = get_now_ist()
1491
 
1492
  # --- Duplicate detection ---
 
1493
  try:
1494
- dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
 
 
 
 
1495
  except Exception:
1496
- dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
 
 
 
 
 
 
1497
 
1498
  # --- RAG Knowledge Base Check ---
1499
  rag_match = None
@@ -1509,26 +1093,30 @@ async def analyze_only(request_body: TicketRequest):
1509
 
1510
  # --- Reasoning ---
1511
  decision_factors = []
1512
- if classification["confidence"] > request_body.confidence_threshold:
1513
- decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1514
- if entities:
1515
- decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
1516
- if dup_result["is_duplicate"]:
1517
- decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
1518
- if rag_match:
1519
- decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1520
  if spam_result["is_spam"]:
1521
- decision_factors.append(
1522
- f"Flagged as spam/phishing (risk {spam_result['risk_score']:.2f})"
1523
- )
1524
- classification["assigned_team"] = "Spam / Suspicious"
1525
- classification["auto_resolve"] = False
 
 
 
 
 
 
1526
 
1527
- reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1528
- if classification["auto_resolve"]:
1529
- reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1530
- if spam_result["is_spam"]:
1531
- reasoning += " Ticket flagged as spam/phishing and quarantined from agent inbox."
 
 
 
 
 
 
1532
 
1533
  timeline["routed"] = get_now_ist()
1534
 
@@ -1536,10 +1124,8 @@ async def analyze_only(request_body: TicketRequest):
1536
  if gemini_service and gemini_service._initialized:
1537
  summary = gemini_service.get_summary(text)
1538
 
1539
- # Convert priority to SLA breached timestamp (for preview)
1540
- hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
1541
- sla_hours = hours_map.get(classification["priority"], 72)
1542
- sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
1543
 
1544
  return TicketResponse(
1545
  ticket_id=str(uuid.uuid4()), # Temporary ID
@@ -1552,22 +1138,18 @@ async def analyze_only(request_body: TicketRequest):
1552
  entities=[EntityInfo(**e) for e in entities],
1553
  duplicate_ticket=DuplicateInfo(**dup_result),
1554
  confidence=classification["confidence"],
1555
- needs_review=classification["confidence"] < 0.20,
1556
  reasoning=reasoning,
1557
  decision_factors=decision_factors,
1558
  image_description=gemini_analysis["image_description"],
1559
  ocr_text=gemini_analysis["ocr_text"],
1560
- highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1561
  timeline=timeline,
1562
  env_metadata=env_metadata,
1563
- spam_check=SpamCheck(**spam_result),
1564
  is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1565
  parent_ticket_id=dup_result.get("parent_ticket_id"),
1566
- sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z"),
1567
- original_text=translation_ctx["original_text"],
1568
- source_language=translation_ctx["source_language"],
1569
- source_language_name=translation_ctx["source_language_name"],
1570
- was_translated=translation_ctx["was_translated"],
1571
  )
1572
 
1573
  @app.post("/ai/analyze_stream")
@@ -1586,7 +1168,11 @@ async def analyze_stream(request_body: TicketRequest):
1586
  "model_version": "3.0.0-PRO",
1587
  "api_endpoint": "/ai/analyze_stream"
1588
  }
1589
- timeline = {"received": get_now_ist()}
 
 
 
 
1590
 
1591
  # 1. Reading
1592
  yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
@@ -1602,15 +1188,6 @@ async def analyze_stream(request_body: TicketRequest):
1602
 
1603
  summary = text[:100] + ("…" if len(text) > 100 else "")
1604
 
1605
- # Spam / Phishing check (silent step — does not get its own SSE event)
1606
- try:
1607
- spam_result = spam_service.check(text, gemini_analysis.get("ocr_text", ""))
1608
- except Exception:
1609
- spam_result = {
1610
- "is_spam": False, "risk_score": 0.0, "reasons": [],
1611
- "suspicious_urls": [], "matched_keywords": [],
1612
- }
1613
-
1614
  # 2. NER
1615
  yield f"data: {json.dumps({'step': 'Extracting technical entities', 'status': 'in_progress'})}\n\n"
1616
  await asyncio.sleep(0.2)
@@ -1623,7 +1200,33 @@ async def analyze_stream(request_body: TicketRequest):
1623
  # 3. Classification
1624
  yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
1625
  await asyncio.sleep(0.2)
1626
- classification = classify_ticket_text(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1627
  timeline["ai_analyzed"] = get_now_ist()
1628
  timeline["triaged"] = get_now_ist()
1629
 
@@ -1631,9 +1234,20 @@ async def analyze_stream(request_body: TicketRequest):
1631
  yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
1632
  await asyncio.sleep(0.2)
1633
  try:
1634
- dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
 
 
 
 
 
1635
  except Exception:
1636
- dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
 
 
 
 
 
 
1637
 
1638
  # 5. RAG / Solutions
1639
  yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
@@ -1649,7 +1263,7 @@ async def analyze_stream(request_body: TicketRequest):
1649
  pass
1650
 
1651
  decision_factors = []
1652
- if classification["confidence"] > request_body.confidence_threshold:
1653
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1654
  if entities:
1655
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
@@ -1657,27 +1271,19 @@ async def analyze_stream(request_body: TicketRequest):
1657
  decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
1658
  if rag_match:
1659
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1660
- if spam_result["is_spam"]:
1661
- decision_factors.append(
1662
- f"Flagged as spam/phishing (risk {spam_result['risk_score']:.2f})"
1663
- )
1664
- classification["assigned_team"] = "Spam / Suspicious"
1665
- classification["auto_resolve"] = False
1666
 
 
 
1667
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1668
  if classification["auto_resolve"]:
1669
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1670
- if spam_result["is_spam"]:
1671
- reasoning += " Ticket flagged as spam/phishing and quarantined from agent inbox."
1672
 
1673
  timeline["routed"] = get_now_ist()
1674
 
1675
  if gemini_service and gemini_service._initialized:
1676
  summary = gemini_service.get_summary(text)
1677
 
1678
- hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
1679
- sla_hours = hours_map.get(classification["priority"], 72)
1680
- sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
1681
 
1682
  ticket_response_dict = {
1683
  "ticket_id": str(uuid.uuid4()),
@@ -1690,16 +1296,17 @@ async def analyze_stream(request_body: TicketRequest):
1690
  "entities": [e for e in entities],
1691
  "duplicate_ticket": dup_result,
1692
  "confidence": classification["confidence"],
1693
- "needs_review": classification["confidence"] < 0.20,
1694
  "reasoning": reasoning,
1695
  "decision_factors": decision_factors,
1696
  "image_description": gemini_analysis["image_description"],
1697
  "ocr_text": gemini_analysis["ocr_text"],
1698
- "highlights": [e.get("text", "") for e in entities],
1699
  "timeline": timeline,
1700
  "env_metadata": env_metadata,
1701
- "spam_check": spam_result,
1702
- "sla_breach_at": sla_breach_dt.isoformat() + "Z"
 
1703
  }
1704
 
1705
  # 6. Final Result
@@ -1731,266 +1338,3 @@ async def analyze_ticket_v2(request: TicketRequest):
1731
  }
1732
  except Exception as e:
1733
  raise HTTPException(status_code=500, detail=str(e))
1734
-
1735
-
1736
- # ---------------------------------------------------------------------------
1737
- # SLA Engine Endpoints
1738
- # ---------------------------------------------------------------------------
1739
-
1740
- class SLAStatsResponse(BaseModel):
1741
- total: int = 0
1742
- active: int = 0
1743
- breached: int = 0
1744
- warning: int = 0
1745
- met: int = 0
1746
- breach_rate: float = 0.0
1747
- by_priority: dict = {}
1748
-
1749
-
1750
- @app.get("/sla/stats", response_model=SLAStatsResponse)
1751
- async def sla_stats():
1752
- """Get aggregated SLA dashboard statistics across all tickets."""
1753
- if not supabase:
1754
- raise HTTPException(status_code=503, detail="Database not connected")
1755
- stats = await sla_engine.get_dashboard_stats()
1756
- if "error" in stats:
1757
- raise HTTPException(status_code=500, detail=stats["error"])
1758
- return stats
1759
-
1760
-
1761
- class SLATicketInfo(BaseModel):
1762
- id: str
1763
- ticket_id: str | None = None
1764
- subject: str | None = None
1765
- summary: str | None = None
1766
- priority: str = "medium"
1767
- status: str | None = None
1768
- assigned_team: str | None = None
1769
- sla_status: str = "active"
1770
- escalation_level: int = 0
1771
- remaining_seconds: int = 0
1772
- created_at: str | None = None
1773
- sla_breach_at: str | None = None
1774
- sla_warning_at: str | None = None
1775
- last_escalated_at: str | None = None
1776
-
1777
-
1778
- @app.get("/sla/tickets")
1779
- async def sla_tickets(
1780
- status: str | None = None,
1781
- priority: str | None = None,
1782
- limit: int = 100,
1783
- offset: int = 0,
1784
- ):
1785
- """
1786
- List tickets with SLA status. Filter by sla_status and/or priority.
1787
- """
1788
- if not supabase:
1789
- raise HTTPException(status_code=503, detail="Database not connected")
1790
-
1791
- query = (
1792
- supabase.table("tickets")
1793
- .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")
1794
- .order("created_at", desc=True)
1795
- )
1796
-
1797
- if status and status != "all":
1798
- query = query.eq("sla_status", status)
1799
- if priority and priority != "all":
1800
- query = query.eq("priority", priority.capitalize())
1801
-
1802
- query = query.range(offset, offset + limit - 1)
1803
- res = query.execute()
1804
- return {"tickets": res.data or [], "total": len(res.data or [])}
1805
-
1806
-
1807
- class EscalationLogEntry(BaseModel):
1808
- id: str
1809
- ticket_id: str | None = None
1810
- ticket_subject: str = ""
1811
- priority: str = "medium"
1812
- sla_status: str = ""
1813
- escalation_level: int = 0
1814
- remaining_seconds: int = 0
1815
- assigned_team: str = ""
1816
- notification_channels: list = []
1817
- triggered_at: str | None = None
1818
- resolved_at: str | None = None
1819
- notes: str = ""
1820
-
1821
-
1822
- @app.get("/sla/escalations")
1823
- async def sla_escalations(limit: int = 50, offset: int = 0):
1824
- """Fetch escalation log history."""
1825
- if not supabase:
1826
- raise HTTPException(status_code=503, detail="Database not connected")
1827
-
1828
- try:
1829
- res = (
1830
- supabase.table("escalation_logs")
1831
- .select("*")
1832
- .order("triggered_at", desc=True)
1833
- .range(offset, offset + limit - 1)
1834
- .execute()
1835
- )
1836
- return {"escalations": res.data or [], "total": len(res.data or [])}
1837
- except Exception as e:
1838
- # Table might not exist yet
1839
- print(f"[SLA] Escalation logs query failed: {e}")
1840
- return {"escalations": [], "total": 0}
1841
-
1842
-
1843
- class SLAPolicyInfo(BaseModel):
1844
- id: str
1845
- priority: str
1846
- max_hours: int
1847
- warning_pct: float
1848
- auto_escalate: bool
1849
- l2_after_minutes: int
1850
- l3_after_minutes: int
1851
-
1852
-
1853
- @app.get("/sla/policies")
1854
- async def sla_policies():
1855
- """Get configured SLA policies."""
1856
- if not supabase:
1857
- # Return defaults from code
1858
- policies = []
1859
- policy_source = sla_engine.SLA_POLICIES if hasattr(sla_engine, "SLA_POLICIES") else {}
1860
- for pri, cfg in policy_source.items():
1861
- policies.append({
1862
- "priority": pri,
1863
- "max_hours": cfg["max_hours"],
1864
- "warning_pct": cfg["warning_pct"],
1865
- "auto_escalate": cfg.get("auto_escalate_on_breach", False),
1866
- "l2_after_minutes": cfg.get("l2_escalation_mins", 0),
1867
- "l3_after_minutes": cfg.get("l3_escalation_mins", 0),
1868
- })
1869
- return {"policies": policies}
1870
-
1871
- try:
1872
- res = supabase.table("sla_policies").select("*").execute()
1873
- return {"policies": res.data or []}
1874
- except Exception as e:
1875
- print(f"[SLA] Policies query failed: {e}")
1876
- return {"policies": []}
1877
-
1878
-
1879
- @app.post("/sla/check")
1880
- async def trigger_sla_check():
1881
- """Manually trigger an SLA evaluation cycle (admin)."""
1882
- if not supabase:
1883
- raise HTTPException(status_code=503, detail="Database not connected")
1884
-
1885
- asyncio.create_task(sla_engine.check_all_active_tickets())
1886
- return {"status": "triggered", "message": "SLA check cycle started in background"}
1887
-
1888
-
1889
- # ---------------------------------------------------------------------------
1890
- # Semantic Duplicate Detection Endpoints
1891
- # ---------------------------------------------------------------------------
1892
-
1893
- @app.post("/ai/check_duplicate")
1894
- async def check_duplicate_endpoint(
1895
- body: TicketRequest,
1896
- company_id: str | None = None,
1897
- ):
1898
- """
1899
- Check a ticket text for potential duplicates using semantic vector search.
1900
- Returns top candidates with similarity scores.
1901
- """
1902
- text = (body.text or "").strip()
1903
- if not text:
1904
- raise HTTPException(status_code=400, detail="No text provided")
1905
-
1906
- threshold = body.duplicate_sensitivity if hasattr(body, 'duplicate_sensitivity') else None
1907
- result = await semantic_dupe_service.check_duplicate(
1908
- text=text,
1909
- company_id=company_id or body.company,
1910
- threshold=threshold,
1911
- )
1912
- return result
1913
-
1914
-
1915
- @app.post("/ai/reindex_embeddings")
1916
- async def reindex_embeddings():
1917
- """Re-generate vector embeddings for all tickets."""
1918
- result = await semantic_dupe_service.reindex_all()
1919
- return result
1920
-
1921
-
1922
- @app.get("/system/settings")
1923
- async def get_system_settings_endpoint():
1924
- """Fetch all system settings."""
1925
- _logger = logging.getLogger(__name__)
1926
- if not supabase:
1927
- raise HTTPException(status_code=503, detail="Database not connected")
1928
- try:
1929
- res = supabase.table("system_settings").select("*").execute()
1930
- settings = {}
1931
- for row in res.data or []:
1932
- settings[row["key"]] = row["value"]
1933
- return settings
1934
- except Exception as e:
1935
- _logger.warning(f"[SETTINGS] Query failed: {e}")
1936
- return {}
1937
-
1938
-
1939
- @app.patch("/system/settings")
1940
- async def update_system_settings(body: dict):
1941
- """Update a specific system setting."""
1942
- if not supabase:
1943
- raise HTTPException(status_code=503, detail="Database not connected")
1944
- key = body.get("key")
1945
- value = body.get("value")
1946
- if not key or value is None:
1947
- raise HTTPException(status_code=400, detail="key and value required")
1948
- try:
1949
- supabase.table("system_settings").upsert({
1950
- "key": key,
1951
- "value": value,
1952
- "updated_at": datetime.datetime.utcnow().isoformat() + "Z",
1953
- }).execute()
1954
- return {"status": "updated", "key": key}
1955
- except Exception as e:
1956
- raise HTTPException(status_code=500, detail=str(e))
1957
-
1958
-
1959
- @app.get("/sla/tickets/{ticket_id}")
1960
- async def sla_ticket_detail(ticket_id: str):
1961
- """Get detailed SLA info for a specific ticket."""
1962
- if not supabase:
1963
- raise HTTPException(status_code=503, detail="Database not connected")
1964
-
1965
- # Fetch ticket
1966
- res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
1967
- if not res.data:
1968
- raise HTTPException(status_code=404, detail="Ticket not found")
1969
-
1970
- ticket = res.data
1971
- result = sla_engine.evaluate_ticket(ticket)
1972
-
1973
- # Fetch escalation history for this ticket
1974
- try:
1975
- esc_res = (
1976
- supabase.table("escalation_logs")
1977
- .select("*")
1978
- .eq("ticket_id", ticket_id)
1979
- .order("triggered_at", desc=True)
1980
- .execute()
1981
- )
1982
- escalations = esc_res.data or []
1983
- except Exception:
1984
- escalations = []
1985
-
1986
- return {
1987
- "ticket": ticket,
1988
- "sla_evaluation": result,
1989
- "escalations": escalations,
1990
- }
1991
-
1992
-
1993
- @app.get("/metrics")
1994
- async def metrics():
1995
- """Prometheus scrape endpoint — exposes AI inference latency, request counts, and tokens."""
1996
- return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
 
8
  import sys
9
  import uuid
10
  import json
 
11
  import datetime
12
  import traceback
13
  import warnings
 
19
  warnings.filterwarnings("ignore", message="'pin_memory'")
20
 
21
  # HF Rebuild Trigger: 2026-03-08-2030
22
+ from fastapi import FastAPI, Depends, Header, HTTPException, Request, Response
23
  from slowapi import Limiter, _rate_limit_exceeded_handler
24
+ from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
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
  env_path = Path(__file__).parent / '.env'
37
  load_dotenv(dotenv_path=env_path)
38
 
39
+ # Apply database encryption for PII fields
40
+ try:
41
+ from backend.auth.crypto import apply_db_encryption_patch
42
+ apply_db_encryption_patch()
43
+ except Exception as e:
44
+ print(f"[WARNING] Database encryption patch initialization failed: {e}")
45
 
 
 
46
 
47
  # Initialize Supabase Client (Service Role for backend bypass)
48
  try:
 
53
  print("[ERROR] SUPABASE_URL or SUPABASE_SERVICE_KEY not set in backend/.env")
54
  supabase = None
55
  else:
56
+ supabase = create_client(url, key)
 
57
  except (ImportError, Exception) as e:
58
  print(f"[WARNING] Supabase initialization failed: {e}")
59
  supabase = None
 
65
  from backend.services.classifier_service import ClassifierService
66
  from backend.services.classifier_v2 import classifier_v2
67
  from backend.services.classifier_v3 import classifier_v3 # V3 Power Model
 
 
68
  from backend.services.ner_service import NERService
69
  from backend.services.duplicate_service import DuplicateService
 
70
  from backend.services.rag_service import RagService
71
+ from backend.services.sla_service import (
72
+ calculate_sla_breach_at,
73
+ calculate_sla_response_at,
74
+ classify_sla_status,
75
+ load as load_sla_service,
76
+ run_sla_escalation_loop,
77
+ )
78
+ from backend.services.spam_detector_service import analyze_spam_phishing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
 
81
  # ---------------------------------------------------------------------------
 
124
  duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
125
  duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
126
  return duplicate_result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  class TicketRequest(BaseModel):
128
  text: str
129
  image_base64: str = ""
130
  image_text: str = "" # Keep for backward compatibility
131
  user_id: str | None = None
132
  company: str | None = None
133
+ company_id: str | None = None
134
  image_url: str | None = None
135
  confidence_threshold: float = 0.20
136
  duplicate_sensitivity: float = 0.85
 
150
  image_url: str | None = None
151
  company: str | None = None
152
  company_id: str | None = None
153
+ description_vector: list[float] | None = None
154
+ is_potential_duplicate: bool = False
155
+ parent_ticket_id: str | None = None
156
+ sla_response_due_at: str | None = None
157
  sla_breach_at: str
158
  sla_status: str | None = None
159
  escalation_level: int = 0
 
169
  class DuplicateInfo(BaseModel):
170
  is_duplicate: bool
171
  duplicate_ticket_id: str | None = None
172
+ parent_ticket_id: str | None = None
173
+ is_potential_duplicate: bool = False
174
  similarity: float = 0.0
175
 
176
 
 
180
  confidence: float
181
 
182
 
 
 
 
 
 
 
 
 
183
  class TicketResponse(BaseModel):
184
  id: str | int | None = None
185
  ticket_id: str | None = None
 
192
  entities: list[EntityInfo]
193
  duplicate_ticket: DuplicateInfo
194
  confidence: float
195
+ is_potential_duplicate: bool = False
196
+ parent_ticket_id: str | None = None
197
  needs_review: bool = False
198
  reasoning: str = ""
199
  decision_factors: list[str] = []
 
203
  timeline: dict = {} # Map of step_name: timestamp
204
  env_metadata: dict = {} # IP, Hostname, Browser/OS
205
  sla_breach_at: str | None = None
206
+ spam_analysis: dict | None = None
 
 
 
 
207
  version: str = "2.1.0-Neural-Diagnostic"
208
 
209
 
 
231
  timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
232
 
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  # --- In-Memory Database (to be replaced with SQL later) ---
235
  TICKETS_DB: list[TicketRecord] = []
236
 
 
253
  ner_service = NERService()
254
  duplicate_service = DuplicateService()
255
  rag_service = RagService()
 
 
 
256
 
257
  try:
258
  from backend.services.gemini_service import GeminiService
 
266
  except ImportError:
267
  ocr_service = None
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
  # ---------------------------------------------------------------------------
271
  # Lifespan (startup / shutdown)
 
274
  async def lifespan(app: FastAPI):
275
  """Load all models at startup."""
276
  print("[Startup] Loading AI models ...")
 
 
 
 
277
  try:
278
  classifier_service.load()
279
+ except Exception as e:
280
  print(f"[WARNING] Classifier not loaded: {e}")
281
  try:
282
  ner_service.load()
283
+ except Exception as e:
284
  print(f"[WARNING] NER not loaded: {e}")
285
  try:
286
  duplicate_service.load()
 
290
  rag_service.load()
291
  except Exception as e:
292
  print(f"[WARNING] RAG service not loaded: {e}")
 
 
 
 
293
 
294
  if gemini_service:
295
  print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
296
  else:
297
  print("[Startup] Gemini Service: NOT LOADED (Import failed)")
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  print("[Startup] Classifier V2 Shadow: Ready.")
 
300
  print("[Startup] Ready.")
301
+ # Strict health checks: fail loudly when core model assets are unavailable.
302
+ # Set ALLOW_DEGRADED_STARTUP=1 to permit degraded startup for local/dev convenience.
303
+ try:
304
+ strict_mode = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") != "1"
305
+ except Exception:
306
+ strict_mode = True
307
 
308
+ classifier_loaded_flag = getattr(classifier_service, "_loaded", False)
309
+ ner_loaded_flag = getattr(ner_service, "_loaded", False)
 
310
 
311
+ if strict_mode and not classifier_loaded_flag:
312
+ raise RuntimeError("[Startup-FATAL] Classifier assets not loaded. Set ALLOW_DEGRADED_STARTUP=1 to bypass.")
313
 
314
+ sla_task = None
 
315
  try:
316
+ if supabase and os.environ.get("SLA_ESCALATION_ENABLED", "true").lower() == "true":
317
+ notification_router = None
318
+ try:
319
+ from backend.services.notification_routing import load as load_notification_router
320
+ notification_router = load_notification_router()
321
+ except Exception as e:
322
+ print(f"[WARNING] Notification router not loaded for SLA service: {e}")
323
+ sla_service = load_sla_service(supabase, notification_router)
324
+ interval = int(os.environ.get("SLA_ESCALATION_INTERVAL_SECONDS", "300"))
325
+ sla_task = asyncio.create_task(run_sla_escalation_loop(sla_service, interval_seconds=interval))
326
+ print(f"[Startup] SLA escalation loop enabled ({interval}s interval).")
327
+
328
+ yield
329
+ finally:
330
+ if sla_task:
331
+ sla_task.cancel()
332
+ try:
333
+ await sla_task
334
+ except asyncio.CancelledError:
335
+ pass
336
+ print("[Shutdown] Cleaning up ...")
337
 
338
 
339
  # ---------------------------------------------------------------------------
 
364
  allow_headers=["*"],
365
  )
366
 
 
 
367
 
368
  # ---------------------------------------------------------------------------
369
  # Root & Health check
 
450
  """
451
 
452
 
453
+ async def verify_metrics_token(x_metrics_token: str | None = Header(default=None)):
454
+ expected_token = os.environ.get("METRICS_TOKEN")
455
+ if expected_token and x_metrics_token != expected_token:
456
+ raise HTTPException(status_code=403, detail="Forbidden")
457
+
458
+
459
+ @app.get("/metrics", dependencies=[Depends(verify_metrics_token)])
460
+ def metrics():
461
+ return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
462
+
463
+
464
  @app.get("/health", response_model=HealthResponse)
465
  async def health_check():
466
  return HealthResponse(
 
473
  @app.get("/ready", response_model=ReadinessResponse)
474
  async def readiness_check():
475
  require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
476
+ allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
477
+
478
  checks = {
479
  "api": True,
480
  "classifier_loaded": classifier_service._loaded,
481
  "ner_loaded": ner_service._loaded,
482
+ "duplicate_index_loaded": duplicate_service.is_available(),
483
+ "rag_loaded": rag_service.is_available(),
484
  }
485
  if require_supabase:
486
  checks["supabase_configured"] = supabase is not None
487
 
488
+ # In degraded mode, duplicate and RAG services are optional
489
+ if allow_degraded:
490
+ required_checks = {k: v for k, v in checks.items() if k not in ["duplicate_index_loaded", "rag_loaded"]}
491
+ all_required_pass = all(required_checks.values())
492
+
493
+ if all_required_pass:
494
+ return ReadinessResponse(status="ready", checks=checks)
495
+ else:
496
+ # Strict mode: all checks must pass
497
+ if all(checks.values()):
498
+ return ReadinessResponse(status="ready", checks=checks)
499
 
500
  return JSONResponse(
501
  status_code=503,
 
623
  # ---------------------------------------------------------------------------
624
  # Ticket operations (Now via Supabase)
625
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  @app.get("/tickets")
627
+ async def get_tickets(company_id: str | None = None):
 
 
 
628
  """Fetch persistent tickets from Supabase."""
629
  if not supabase:
630
  raise HTTPException(status_code=500, detail="Database connection not initialized")
 
 
 
631
 
632
  query = supabase.table("tickets").select("*").order("created_at", desc=True)
633
+ if company_id:
634
+ query = query.eq("company_id", company_id)
635
 
636
  res = query.execute()
637
  return res.data
638
 
639
+ @app.get("/tickets/search")
640
+ async def search_tickets(q: str | None = None, company_id: str | None = None, limit: int = 50, offset: int = 0):
641
+ """Search tickets using tenant-safe full-text search."""
642
+ if not supabase:
643
+ raise HTTPException(status_code=500, detail="Database connection not initialized")
644
+
645
+ if not q:
646
+ raise HTTPException(status_code=400, detail="Search query is required")
647
+ if not company_id:
648
+ raise HTTPException(status_code=400, detail="company_id is required for tenant-safe search")
649
+
650
+ try:
651
+ result = supabase.rpc(
652
+ "search_tickets",
653
+ {
654
+ "query_text": q,
655
+ "company_id": company_id,
656
+ "limit_rows": limit,
657
+ "offset_rows": offset,
658
+ },
659
+ ).execute()
660
+ return result.data or []
661
+ except Exception as e:
662
+ raise HTTPException(status_code=500, detail=f"Search failed: {e}")
663
+
664
  @app.post("/tickets/save")
665
  async def save_ticket(request_body: TicketSaveRequest):
666
  """
 
671
  raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
672
 
673
  logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
  try:
675
+ final_data = request_body.dict()
676
+
677
+ # Resolve tenant linkage from user profile with authorization validation.
678
+ profile = {}
679
+ if request_body.user_id:
680
+ try:
681
+ profile_res = (
682
+ supabase.table("profiles")
683
+ .select("company_id, company")
684
+ .eq("id", request_body.user_id)
685
+ .single()
686
+ .execute()
687
+ )
688
+ profile = profile_res.data or {}
689
+ if not profile:
690
+ raise HTTPException(status_code=404, detail="User profile not found")
691
+
692
+ # SELF-HEALING: If company_id is null in database but company name exists, resolve it!
693
+ if not profile.get("company_id") and profile.get("company"):
694
+ try:
695
+ comp_name = profile.get("company").strip()
696
+ comp_res = (
697
+ supabase.table("companies")
698
+ .select("id")
699
+ .ilike("name", comp_name)
700
+ .execute()
701
+ )
702
+ if comp_res.data:
703
+ resolved_company_id = comp_res.data[0]["id"]
704
+ # Backfill the profile table in real-time
705
+ supabase.table("profiles").update({"company_id": resolved_company_id}).eq("id", request_body.user_id).execute()
706
+ profile["company_id"] = resolved_company_id
707
+ logger.info(f"[SELF-HEALING] Backfilled company_id={resolved_company_id} for user={request_body.user_id}")
708
+ except Exception as healing_err:
709
+ logger.warning(f"[SELF-HEALING WARNING] Failed to backfill company_id: {healing_err}")
710
+ except HTTPException:
711
+ raise
712
+ except Exception as profile_error:
713
+ user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
714
+ logger.error(f"Tenant resolution error for user {user_hash}: {profile_error}")
715
+ raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
716
+
717
+ # Validate tenant consistency and authorization.
718
+ profile_company_id = profile.get("company_id")
719
+ if final_data.get("company_id"):
720
+ # User provided company_id: verify it matches their profile.
721
+ if profile_company_id and final_data["company_id"] != profile_company_id:
722
+ user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
723
+ logger.warning(f"Tenant mismatch: user {user_hash} attempted {final_data['company_id']}, assigned to {profile_company_id}")
724
+ raise HTTPException(status_code=403, detail="User not authorized for this tenant")
725
+ elif profile_company_id:
726
+ # Backfill company_id from profile.
727
+ final_data["company_id"] = profile_company_id
728
+ elif request_body.user_id:
729
+ # User has no tenant assignment.
730
+ raise HTTPException(status_code=400, detail="User has no tenant assignment")
731
+
732
  # Backfill company name if missing.
733
  if not final_data.get("company") and profile.get("company"):
734
  final_data["company"] = profile["company"]
 
741
  final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
742
  final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
743
 
744
+ import hashlib
745
  user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
746
  logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
747
 
748
  duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
749
+ duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85)
750
+ duplicate_result = {
751
+ "is_duplicate": False,
752
+ "duplicate_ticket_id": None,
753
+ "parent_ticket_id": None,
754
+ "is_potential_duplicate": False,
755
+ "similarity": 0.0,
756
+ }
757
 
758
+ if duplicate_text:
759
+ duplicate_result = detect_semantic_duplicate(
760
+ duplicate_text,
761
+ company_id=final_data.get("company_id"),
762
+ threshold=duplicate_threshold,
763
+ )
764
+ final_data["description_vector"] = duplicate_service.generate_embedding(duplicate_text)
765
+ else:
766
+ final_data["description_vector"] = None
767
 
768
+ final_data["is_potential_duplicate"] = duplicate_result.get("is_potential_duplicate", False)
769
+ final_data["parent_ticket_id"] = duplicate_result.get("parent_ticket_id")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
 
771
  # --- Sanitize payload to only include valid Supabase DB columns ---
772
  # Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
 
774
  VALID_TICKET_COLUMNS = {
775
  "user_id", "subject", "description", "category", "subcategory",
776
  "priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
777
+ "confidence", "image_url", "company", "company_id", "sla_breach_at", "metadata",
 
778
  }
779
  # Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
780
  existing_metadata = final_data.get("metadata") or {}
781
  extra_keys = (
782
  "entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
783
+ "is_potential_duplicate", "parent_ticket_id", "sla_response_due_at", "sla_status", "escalation_level",
784
+ "spam_analysis"
785
  )
786
  for extra_key in extra_keys:
787
  if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
 
798
 
799
  ticket_id = res.data[0]["id"]
800
 
801
+ duplicate_indexed = True
802
+ duplicate_index_warning = None
 
 
 
 
 
 
 
 
 
 
 
 
 
803
  if duplicate_text:
804
  try:
 
805
  duplicate_service.add_ticket(str(ticket_id), duplicate_text)
 
 
806
  except Exception as index_error:
807
+ duplicate_indexed = False
808
+ duplicate_index_warning = "Duplicate index update failed."
809
+ print(f"[WARNING] {duplicate_index_warning} ticket_id={ticket_id} error={index_error}")
810
+ else:
811
+ duplicate_indexed = False
812
+ duplicate_index_warning = "Duplicate index update skipped: no description or subject text was provided."
813
+ print(f"[WARNING] {duplicate_index_warning}")
814
 
815
  # Add initial system diagnostic message
816
  msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
 
828
  response = {
829
  "status": "success",
830
  "ticket_id": ticket_id,
831
+ "duplicate_indexed": duplicate_indexed,
832
+ "is_potential_duplicate": final_data["is_potential_duplicate"],
833
+ "parent_ticket_id": final_data["parent_ticket_id"],
834
  }
835
+ if duplicate_index_warning:
836
+ response["duplicate_index_warning"] = duplicate_index_warning
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837
  return response
838
 
839
  except Exception as e:
840
  traceback.print_exc()
841
  raise HTTPException(status_code=500, detail=str(e))
842
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  @app.get("/tickets/{ticket_id}")
844
+ async def get_ticket_by_id(ticket_id: str):
 
 
 
 
845
  """Fetch single persistent ticket."""
846
  if not supabase:
847
  raise HTTPException(status_code=500, detail="Database connection not initialized")
848
+
 
 
 
 
 
 
 
 
 
 
849
  res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
850
  if not res.data:
851
  raise HTTPException(status_code=404, detail="Ticket not found")
 
 
852
  return res.data
853
 
854
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
  @app.post("/tickets", response_model=TicketRecord)
856
  async def create_ticket(ticket: TicketRecord):
857
  """Save a new ticket into the system."""
 
890
  Main endpoint for analyzing a new ticket using the cascade of local AI models.
891
  """
892
  text = request_body.text
893
+
894
+ settings = get_system_settings(request_body.company_id)
895
+ confidence_threshold = settings["ai_confidence_threshold"]
896
+ duplicate_sensitivity = settings["duplicate_sensitivity"]
897
+ enable_auto_resolve = settings["enable_auto_resolve"]
898
 
899
  # Grab client metadata
900
  client_ip = request.client.host if request.client else "unknown"
 
916
  text = f"{text} {local_ocr_text}".strip()
917
  print(f"[AI] OCR added {len(local_ocr_text)} chars to context.")
918
 
919
+ # Initalize Timeline
920
+ return await analyze_only(request_body)
 
921
 
922
  @app.post("/ai/analyze")
923
  async def analyze_only(request_body: TicketRequest):
 
927
  and duplicate check before committing to a ticket creation.
928
  """
929
  text = request_body.text
 
 
930
  print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
931
  settings = get_system_settings(request_body.company)
932
  confidence_threshold = settings["ai_confidence_threshold"]
 
968
  highlights=[],
969
  timeline={"received": _dt.datetime.utcnow().isoformat() + "Z"},
970
  env_metadata={},
971
+ is_potential_duplicate=False,
972
+ parent_ticket_id=None,
973
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
 
 
 
 
974
  )
975
 
976
  # --- Context & Environment ---
 
1002
 
1003
  summary = text[:100] + ("…" if len(text) > 100 else "")
1004
 
1005
+ # --- Spam & Phishing Detection Layer ---
1006
+ spam_result = analyze_spam_phishing(text, gemini_analysis.get("ocr_text", ""))
1007
+
1008
+ # --- Classification ---
1009
  try:
1010
+ classification_v3_res = classifier_v3.predict(text)
1011
+ if "error" in classification_v3_res:
1012
+ # Fallback to V1
1013
+ classification = classifier_service.predict(text)
1014
+ else:
1015
+ # Parse V3 output
1016
+ cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
1017
+ sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
1018
+ pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
1019
+ conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
1020
+
1021
+ from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
1022
+ assigned_team = TEAM_MAP.get(cat, "General Support")
1023
+ auto_resolve = sub in AUTO_RESOLVE_SUBS
1024
+
1025
+ classification = {
1026
+ "category": cat,
1027
+ "subcategory": sub,
1028
+ "priority": pri,
1029
+ "auto_resolve": auto_resolve,
1030
+ "assigned_team": assigned_team,
1031
+ "confidence": float(conf)
1032
+ }
1033
  except Exception as e:
1034
+ traceback.print_exc()
1035
+ classification = {
1036
+ "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
1037
+ "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
1038
  }
1039
 
1040
+ # Apply Spam overrides if spam/phishing is detected
1041
+ if spam_result["is_spam"]:
1042
+ classification["category"] = "Spam"
1043
+ if spam_result["risk_level"] == "high":
1044
+ classification["subcategory"] = "Suspicious Phishing"
1045
+ elif spam_result["risk_level"] == "medium":
1046
+ classification["subcategory"] = "Spam Inquiry"
1047
+ else:
1048
+ classification["subcategory"] = "Low-Risk Spam"
1049
+ classification["priority"] = "Low"
1050
+ classification["assigned_team"] = "Security Unit"
1051
+ classification["auto_resolve"] = False
1052
+ classification["confidence"] = max(classification["confidence"], 0.95)
1053
 
1054
  timeline["ai_analyzed"] = get_now_ist()
1055
  timeline["triaged"] = get_now_ist()
 
1063
  timeline["metadata_harvested"] = get_now_ist()
1064
 
1065
  # --- Duplicate detection ---
1066
+ duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
1067
  try:
1068
+ dup_result = detect_semantic_duplicate(
1069
+ text,
1070
+ company_id=request_body.company_id,
1071
+ threshold=duplicate_threshold,
1072
+ )
1073
  except Exception:
1074
+ dup_result = {
1075
+ "is_duplicate": False,
1076
+ "duplicate_ticket_id": None,
1077
+ "parent_ticket_id": None,
1078
+ "is_potential_duplicate": False,
1079
+ "similarity": 0.0,
1080
+ }
1081
 
1082
  # --- RAG Knowledge Base Check ---
1083
  rag_match = None
 
1093
 
1094
  # --- Reasoning ---
1095
  decision_factors = []
 
 
 
 
 
 
 
 
1096
  if spam_result["is_spam"]:
1097
+ decision_factors.append(f"Spam/Phishing detected (Risk: {spam_result['risk_level'].upper()})")
1098
+ reasoning = f"Flagged as potential spam/phishing. Reasons: {', '.join(spam_result['reasons'])}"
1099
+ else:
1100
+ if classification["confidence"] > confidence_threshold:
1101
+ decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1102
+ if entities:
1103
+ decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
1104
+ if dup_result["is_duplicate"]:
1105
+ decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
1106
+ if rag_match:
1107
+ decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1108
 
1109
+ reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1110
+ if (
1111
+ enable_auto_resolve
1112
+ and classification["confidence"] >= confidence_threshold
1113
+ and classification["auto_resolve"]
1114
+ ):
1115
+ classification["auto_resolve"] = True
1116
+ else:
1117
+ classification["auto_resolve"] = False
1118
+ if classification["auto_resolve"]:
1119
+ reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1120
 
1121
  timeline["routed"] = get_now_ist()
1122
 
 
1124
  if gemini_service and gemini_service._initialized:
1125
  summary = gemini_service.get_summary(text)
1126
 
1127
+ # Convert priority to the SLA resolution target timestamp for preview.
1128
+ sla_breach_dt = calculate_sla_breach_at(classification["priority"])
 
 
1129
 
1130
  return TicketResponse(
1131
  ticket_id=str(uuid.uuid4()), # Temporary ID
 
1138
  entities=[EntityInfo(**e) for e in entities],
1139
  duplicate_ticket=DuplicateInfo(**dup_result),
1140
  confidence=classification["confidence"],
1141
+ needs_review=classification["confidence"] < confidence_threshold,
1142
  reasoning=reasoning,
1143
  decision_factors=decision_factors,
1144
  image_description=gemini_analysis["image_description"],
1145
  ocr_text=gemini_analysis["ocr_text"],
1146
+ highlights=entities, # Use entities as highlights for now
1147
  timeline=timeline,
1148
  env_metadata=env_metadata,
1149
+ spam_analysis=spam_result,
1150
  is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1151
  parent_ticket_id=dup_result.get("parent_ticket_id"),
1152
+ sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
 
 
 
 
1153
  )
1154
 
1155
  @app.post("/ai/analyze_stream")
 
1168
  "model_version": "3.0.0-PRO",
1169
  "api_endpoint": "/ai/analyze_stream"
1170
  }
1171
+ timeline = {"received": get_now_ist()}
1172
+ settings = get_system_settings(request_body.company_id)
1173
+ confidence_threshold = settings["ai_confidence_threshold"]
1174
+ duplicate_sensitivity = settings["duplicate_sensitivity"]
1175
+ enable_auto_resolve = settings["enable_auto_resolve"]
1176
 
1177
  # 1. Reading
1178
  yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
 
1188
 
1189
  summary = text[:100] + ("…" if len(text) > 100 else "")
1190
 
 
 
 
 
 
 
 
 
 
1191
  # 2. NER
1192
  yield f"data: {json.dumps({'step': 'Extracting technical entities', 'status': 'in_progress'})}\n\n"
1193
  await asyncio.sleep(0.2)
 
1200
  # 3. Classification
1201
  yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
1202
  await asyncio.sleep(0.2)
1203
+ try:
1204
+ classification_v3_res = classifier_v3.predict(text)
1205
+ if "error" in classification_v3_res:
1206
+ classification = classifier_service.predict(text)
1207
+ else:
1208
+ cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
1209
+ sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
1210
+ pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
1211
+ conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
1212
+
1213
+ from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
1214
+ assigned_team = TEAM_MAP.get(cat, "General Support")
1215
+ auto_resolve = sub in AUTO_RESOLVE_SUBS
1216
+
1217
+ classification = {
1218
+ "category": cat,
1219
+ "subcategory": sub,
1220
+ "priority": pri,
1221
+ "auto_resolve": auto_resolve,
1222
+ "assigned_team": assigned_team,
1223
+ "confidence": float(conf)
1224
+ }
1225
+ except Exception as e:
1226
+ classification = {
1227
+ "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
1228
+ "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
1229
+ }
1230
  timeline["ai_analyzed"] = get_now_ist()
1231
  timeline["triaged"] = get_now_ist()
1232
 
 
1234
  yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
1235
  await asyncio.sleep(0.2)
1236
  try:
1237
+ duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
1238
+ dup_result = detect_semantic_duplicate(
1239
+ text,
1240
+ company_id=request_body.company_id,
1241
+ threshold=duplicate_threshold,
1242
+ )
1243
  except Exception:
1244
+ dup_result = {
1245
+ "is_duplicate": False,
1246
+ "duplicate_ticket_id": None,
1247
+ "parent_ticket_id": None,
1248
+ "is_potential_duplicate": False,
1249
+ "similarity": 0.0,
1250
+ }
1251
 
1252
  # 5. RAG / Solutions
1253
  yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
 
1263
  pass
1264
 
1265
  decision_factors = []
1266
+ if classification["confidence"] > confidence_threshold:
1267
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1268
  if entities:
1269
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
 
1271
  decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
1272
  if rag_match:
1273
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
 
 
 
 
 
 
1274
 
1275
+ if not enable_auto_resolve:
1276
+ classification["auto_resolve"] = False
1277
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1278
  if classification["auto_resolve"]:
1279
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
 
 
1280
 
1281
  timeline["routed"] = get_now_ist()
1282
 
1283
  if gemini_service and gemini_service._initialized:
1284
  summary = gemini_service.get_summary(text)
1285
 
1286
+ sla_breach_dt = calculate_sla_breach_at(classification["priority"])
 
 
1287
 
1288
  ticket_response_dict = {
1289
  "ticket_id": str(uuid.uuid4()),
 
1296
  "entities": [e for e in entities],
1297
  "duplicate_ticket": dup_result,
1298
  "confidence": classification["confidence"],
1299
+ "needs_review": classification["confidence"] < confidence_threshold,
1300
  "reasoning": reasoning,
1301
  "decision_factors": decision_factors,
1302
  "image_description": gemini_analysis["image_description"],
1303
  "ocr_text": gemini_analysis["ocr_text"],
1304
+ "highlights": entities,
1305
  "timeline": timeline,
1306
  "env_metadata": env_metadata,
1307
+ "is_potential_duplicate": dup_result.get("is_potential_duplicate", False),
1308
+ "parent_ticket_id": dup_result.get("parent_ticket_id"),
1309
+ "sla_breach_at": sla_breach_dt.isoformat().replace("+00:00", "Z")
1310
  }
1311
 
1312
  # 6. Final Result
 
1338
  }
1339
  except Exception as e:
1340
  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,11 +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
- cryptography>=42.0.0
25
- websockets>=12.0
26
- prometheus-client>=0.19.0
27
-
 
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.17.0
 
 
 
 
 
 
 
backend/services/classifier_service.py CHANGED
@@ -6,32 +6,29 @@ 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
- try:
26
- from backend.services.metrics_service import (
27
- CLASSIFIER_LATENCY,
28
- CLASSIFIER_REQUESTS,
29
- CLASSIFIER_TOKENS,
30
- )
31
- _METRICS_ENABLED = True
32
- except Exception:
33
- _METRICS_ENABLED = False
34
-
35
  # Priority mapping based on sub-category severity
36
  PRIORITY_MAP = {
37
  "Blue Screen": "Critical", "Overheating": "Critical", "Data Loss": "Critical",
@@ -75,19 +72,26 @@ class ClassifierService:
75
  if self._loaded:
76
  return
77
 
78
- if not _HAS_TORCH:
79
- # Degraded environment: ML runtime not available. Delay failure until predict is called.
80
- print("[INFO] ML runtime not available; classifier will remain unloaded until dependencies are installed.")
81
- return
82
-
83
  abs_dir = os.path.abspath(SAVE_DIR)
 
84
 
85
- if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
86
  raise FileNotFoundError(
87
  f"Classifier model not found at {abs_dir}. "
88
  "Please ensure model files are present."
89
  )
90
 
 
 
 
 
 
 
 
 
 
 
 
91
  # Load label mappings
92
  with open(os.path.join(abs_dir, "id2label.json"), "r") as f:
93
  self.id2label = json.load(f)
@@ -109,76 +113,74 @@ class ClassifierService:
109
  """
110
  Predict category, subcategory, priority, auto_resolve, assigned_team, and confidence.
111
  """
112
- self.load()
113
-
114
- encoding = self.tokenizer(
115
- text,
116
- truncation=True,
117
- padding="max_length",
118
- max_length=MAX_LEN,
119
- return_tensors="pt",
120
- )
121
- input_ids = encoding["input_ids"].to(DEVICE)
122
- attention_mask = encoding["attention_mask"].to(DEVICE)
123
-
124
- import time
125
- _t0 = time.perf_counter()
126
  try:
 
 
 
 
 
 
 
 
 
 
 
 
127
  with torch.no_grad():
128
  outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
129
  logits = outputs.logits
130
  probs = F.softmax(logits, dim=1)
131
  confidence, pred_idx = torch.max(probs, dim=1)
132
- except Exception:
133
- if _METRICS_ENABLED:
134
- CLASSIFIER_REQUESTS.labels(model="distilbert", status="error").inc()
135
- raise
136
- if _METRICS_ENABLED:
137
- CLASSIFIER_LATENCY.labels(model="distilbert").observe(time.perf_counter() - _t0)
138
- CLASSIFIER_REQUESTS.labels(model="distilbert", status="ok").inc()
139
- CLASSIFIER_TOKENS.labels(model="distilbert").inc(int(attention_mask.sum().item()))
140
-
141
- pred_idx = pred_idx.item()
142
- confidence = round(confidence.item(), 4)
143
-
144
- # Decode the combined label "Category | SubCategory"
145
- combined_label = self.id2label.get(str(pred_idx), "Unknown | Unknown")
146
- parts = combined_label.split(" | ", 1)
147
- category = parts[0].strip() if len(parts) > 0 else "Unknown"
148
- subcategory = parts[1].strip() if len(parts) > 1 else "Unknown"
149
-
150
- # Derive priority
151
- priority = PRIORITY_MAP.get(subcategory, "Medium")
152
-
153
- # Derive assigned team
154
- assigned_team = TEAM_MAP.get(category, "General Support")
155
-
156
- # Derive auto_resolve
157
- auto_resolve = subcategory in AUTO_RESOLVE_SUBS
158
-
159
- # --- Regex Override Layer (Boost for Technical Keywords) ---
160
- tech_keywords = {
161
- "Network": ["IP address", "hostname", "connection", "network", "bandwidth", "DNS", "firewall", "VPN", "Connectivity", "Latency", "Routing", "Spikes"],
162
- "Software": ["crash", "load", "website", "application", "error", "bug", "failing", "software", "SQL", "Cluster", "Database", "Production", "Latency"],
163
- "Access": ["login", "password", "access", "authentication", "account", "permission", "MFA", "OAuth"]
164
- }
165
-
166
- lower_text = text.lower()
167
- for cat, keywords in tech_keywords.items():
168
- if any(k.lower() in lower_text for k in keywords):
169
- # If current prediction is generic, or we have a high-value technical keyword
170
- if category == "General" or confidence < 0.9:
171
- category = cat
172
- assigned_team = TEAM_MAP.get(cat, "General Support")
173
- # Boost confidence significantly for verified technical signals
174
- confidence = max(confidence, 0.92)
175
- break
176
-
177
- return {
178
- "category": category,
179
- "subcategory": subcategory,
180
- "priority": priority,
181
- "auto_resolve": auto_resolve,
182
- "assigned_team": assigned_team,
183
- "confidence": confidence,
184
- }
 
6
 
7
  import os
8
  import json
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification
12
+ import time
13
+ from prometheus_client import Counter, Histogram
14
+
15
+ # Prometheus metrics for tracking model performance
16
+ MODEL_PREDICTIONS_TOTAL = Counter(
17
+ "model_predictions_total",
18
+ "Total count of DistilBERT predictions",
19
+ ["status"]
20
+ )
21
+
22
+ MODEL_PREDICTION_LATENCY = Histogram(
23
+ "model_prediction_latency_seconds",
24
+ "Latency of DistilBERT prediction in seconds",
25
+ buckets=(0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 10.0)
26
+ )
27
 
28
  SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "classifier")
29
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
30
  MAX_LEN = 128
31
 
 
 
 
 
 
 
 
 
 
 
32
  # Priority mapping based on sub-category severity
33
  PRIORITY_MAP = {
34
  "Blue Screen": "Critical", "Overheating": "Critical", "Data Loss": "Critical",
 
72
  if self._loaded:
73
  return
74
 
 
 
 
 
 
75
  abs_dir = os.path.abspath(SAVE_DIR)
76
+ safetensors_path = os.path.join(abs_dir, "model.safetensors")
77
 
78
+ if not os.path.exists(safetensors_path):
79
  raise FileNotFoundError(
80
  f"Classifier model not found at {abs_dir}. "
81
  "Please ensure model files are present."
82
  )
83
 
84
+ with open(safetensors_path, "rb") as f:
85
+ header = f.read(512)
86
+ if (
87
+ b"version https://git-lfs.github.com/spec" in header
88
+ or b"oid sha256:" in header
89
+ ):
90
+ raise FileNotFoundError(
91
+ f"Classifier model at {abs_dir} is a Git LFS placeholder, not the actual model. "
92
+ "Please pull the LFS assets."
93
+ )
94
+
95
  # Load label mappings
96
  with open(os.path.join(abs_dir, "id2label.json"), "r") as f:
97
  self.id2label = json.load(f)
 
113
  """
114
  Predict category, subcategory, priority, auto_resolve, assigned_team, and confidence.
115
  """
116
+ start_time = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  try:
118
+ self.load()
119
+
120
+ encoding = self.tokenizer(
121
+ text,
122
+ truncation=True,
123
+ padding="max_length",
124
+ max_length=MAX_LEN,
125
+ return_tensors="pt",
126
+ )
127
+ input_ids = encoding["input_ids"].to(DEVICE)
128
+ attention_mask = encoding["attention_mask"].to(DEVICE)
129
+
130
  with torch.no_grad():
131
  outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
132
  logits = outputs.logits
133
  probs = F.softmax(logits, dim=1)
134
  confidence, pred_idx = torch.max(probs, dim=1)
135
+
136
+ pred_idx = pred_idx.item()
137
+ confidence = round(confidence.item(), 4)
138
+
139
+ # Decode the combined label "Category | SubCategory"
140
+ combined_label = self.id2label.get(str(pred_idx), "Unknown | Unknown")
141
+ parts = combined_label.split(" | ", 1)
142
+ category = parts[0].strip() if len(parts) > 0 else "Unknown"
143
+ subcategory = parts[1].strip() if len(parts) > 1 else "Unknown"
144
+
145
+ # Derive priority
146
+ priority = PRIORITY_MAP.get(subcategory, "Medium")
147
+
148
+ # Derive assigned team
149
+ assigned_team = TEAM_MAP.get(category, "General Support")
150
+
151
+ # Derive auto_resolve
152
+ auto_resolve = subcategory in AUTO_RESOLVE_SUBS
153
+
154
+ # --- Regex Override Layer (Boost for Technical Keywords) ---
155
+ tech_keywords = {
156
+ "Network": ["IP address", "hostname", "connection", "network", "bandwidth", "DNS", "firewall", "VPN", "Connectivity", "Latency", "Routing", "Spikes"],
157
+ "Software": ["crash", "load", "website", "application", "error", "bug", "failing", "software", "SQL", "Cluster", "Database", "Production", "Latency"],
158
+ "Access": ["login", "password", "access", "authentication", "account", "permission", "MFA", "OAuth"]
159
+ }
160
+
161
+ lower_text = text.lower()
162
+ for cat, keywords in tech_keywords.items():
163
+ if any(k.lower() in lower_text for k in keywords):
164
+ # If current prediction is generic, or we have a high-value technical keyword
165
+ if category == "General" or confidence < 0.9:
166
+ category = cat
167
+ assigned_team = TEAM_MAP.get(cat, "General Support")
168
+ # Boost confidence significantly for verified technical signals
169
+ confidence = max(confidence, 0.92)
170
+ break
171
+
172
+ MODEL_PREDICTIONS_TOTAL.labels(status="success").inc()
173
+ return {
174
+ "category": category,
175
+ "subcategory": subcategory,
176
+ "priority": priority,
177
+ "auto_resolve": auto_resolve,
178
+ "assigned_team": assigned_team,
179
+ "confidence": confidence,
180
+ }
181
+ except Exception as e:
182
+ MODEL_PREDICTIONS_TOTAL.labels(status="failure").inc()
183
+ raise e
184
+ finally:
185
+ duration = time.time() - start_time
186
+ MODEL_PREDICTION_LATENCY.observe(duration)
 
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,20 +2,11 @@ import os
2
  import base64
3
  import io
4
  import re
5
- import json
 
6
  from dotenv import load_dotenv
7
  from pathlib import Path
8
 
9
- try:
10
- from PIL import Image
11
- from google import genai
12
- _HAS_GEMINI_DEPS = True
13
- except ImportError:
14
- Image = None
15
- genai = None
16
- _HAS_GEMINI_DEPS = False
17
-
18
-
19
  # Load environment variables from backend/.env
20
  env_path = Path(__file__).parent.parent / '.env'
21
  load_dotenv(dotenv_path=env_path)
@@ -26,7 +17,7 @@ class GeminiService:
26
  self._initialized = False
27
  self.model_name = 'gemini-2.5-flash'
28
 
29
- if self.api_key and _HAS_GEMINI_DEPS:
30
  try:
31
  self.client = genai.Client(api_key=self.api_key)
32
  self._initialized = True
@@ -34,18 +25,15 @@ class GeminiService:
34
  except Exception as e:
35
  print(f"[GeminiService] Initialization Error: {e}")
36
  else:
37
- if not _HAS_GEMINI_DEPS:
38
- print("[GeminiService] WARNING: PIL or google-genai package is not installed. Gemini service is disabled.")
39
- else:
40
- print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
41
 
42
- def analyze_image(self, image_base64: str, context_text: str = None) -> dict:
43
  """
44
  Perform OCR and image analysis using Gemini logic.
45
  """
46
- if not self._initialized or not _HAS_GEMINI_DEPS:
47
  return {
48
- "image_description": "[Gemini Service Offline] Could not analyze image.",
49
  "ocr_text": "",
50
  "detected_problem": ""
51
  }
@@ -58,10 +46,6 @@ class GeminiService:
58
 
59
  prompt = (
60
  "Analyze this screenshot from a user reporting a technical issue. "
61
- )
62
- if context_text:
63
- prompt += f"Context/description provided by user: '{context_text}'\n"
64
- prompt += (
65
  "1. Provide a concise description of what is shown in the image. "
66
  "2. Perform OCR and extract any error messages or key text. "
67
  "3. Identify the main technical problem depicted. "
@@ -238,65 +222,3 @@ class GeminiService:
238
  except Exception as e:
239
  print(f"[GeminiService] Bug Analysis Error: {e}")
240
  return f"Diagnostic analysis failed: {str(e)}"
241
-
242
- def detect_language(self, text: str) -> dict:
243
- """
244
- Detect language for the given text. Returns ISO-ish language code and English language name.
245
- """
246
- if not text or not text.strip():
247
- return {"code": "en", "name": "English"}
248
- if not self._initialized:
249
- return {"code": "en", "name": "English"}
250
-
251
- try:
252
- prompt = (
253
- "Detect the natural language of the following user message. "
254
- "Return strict JSON only with keys: code, name. "
255
- "Example: {\"code\":\"es\",\"name\":\"Spanish\"}.\n\n"
256
- f"Text:\n{text}"
257
- )
258
- response = self.client.models.generate_content(
259
- model=self.model_name,
260
- contents=prompt
261
- )
262
- raw = (response.text or "").strip()
263
- match = re.search(r"\{.*\}", raw, re.DOTALL)
264
- parsed = json.loads(match.group(0) if match else raw)
265
- code = str(parsed.get("code", "en")).lower()
266
- name = str(parsed.get("name", "English"))
267
- if not code:
268
- code = "en"
269
- if not name:
270
- name = "English"
271
- return {"code": code, "name": name}
272
- except Exception as e:
273
- print(f"[GeminiService] Language detection error: {e}")
274
- return {"code": "en", "name": "English"}
275
-
276
- def translate_to_english(self, text: str, source_language: str | None = None) -> str:
277
- """
278
- Translate user text to English while preserving technical terms.
279
- """
280
- if not text or not text.strip():
281
- return text
282
- if not self._initialized:
283
- return text
284
-
285
- try:
286
- lang_hint = f"Source language: {source_language}. " if source_language else ""
287
- prompt = (
288
- "Translate the following support ticket text to natural, concise English. "
289
- "Preserve technical terms, error codes, product names, and formatting. "
290
- "Return only translated text with no prefix or explanation. "
291
- f"{lang_hint}\n\n"
292
- f"Text:\n{text}"
293
- )
294
- response = self.client.models.generate_content(
295
- model=self.model_name,
296
- contents=prompt
297
- )
298
- translated = (response.text or "").strip()
299
- return translated or text
300
- except Exception as e:
301
- print(f"[GeminiService] Translation error: {e}")
302
- 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
8
  from pathlib import Path
9
 
 
 
 
 
 
 
 
 
 
 
10
  # Load environment variables from backend/.env
11
  env_path = Path(__file__).parent.parent / '.env'
12
  load_dotenv(dotenv_path=env_path)
 
17
  self._initialized = False
18
  self.model_name = 'gemini-2.5-flash'
19
 
20
+ if self.api_key:
21
  try:
22
  self.client = genai.Client(api_key=self.api_key)
23
  self._initialized = True
 
25
  except Exception as e:
26
  print(f"[GeminiService] Initialization Error: {e}")
27
  else:
28
+ print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
 
 
 
29
 
30
+ def analyze_image(self, image_base64: str) -> dict:
31
  """
32
  Perform OCR and image analysis using Gemini logic.
33
  """
34
+ if not self._initialized:
35
  return {
36
+ "image_description": "[Gemini API Key Missing] Could not analyze image.",
37
  "ocr_text": "",
38
  "detected_problem": ""
39
  }
 
46
 
47
  prompt = (
48
  "Analyze this screenshot from a user reporting a technical issue. "
 
 
 
 
49
  "1. Provide a concise description of what is shown in the image. "
50
  "2. Perform OCR and extract any error messages or key text. "
51
  "3. Identify the main technical problem depicted. "
 
222
  except Exception as e:
223
  print(f"[GeminiService] Bug Analysis Error: {e}")
224
  return f"Diagnostic analysis failed: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
@@ -114,12 +110,11 @@ class SlaEscalationService:
114
  now_fn: Callable[[], datetime] = _utc_now,
115
  notification_router: Any = None,
116
  ):
117
- from backend.auth.crypto import wrap_client
118
- self.supabase = wrap_client(supabase_client)
119
  self.now_fn = now_fn
120
  self.notification_router = notification_router
121
 
122
- def run_once(self, background_tasks: BackgroundTasks | None = None) -> dict[str, int | str]:
123
  stats: dict[str, int | str] = {
124
  "processed_count": 0,
125
  "breached_count": 0,
@@ -141,7 +136,7 @@ class SlaEscalationService:
141
  if not self._should_breach(ticket, now):
142
  stats["skipped_count"] = int(stats["skipped_count"]) + 1
143
  continue
144
- self._breach_ticket(ticket, now, background_tasks=background_tasks)
145
  stats["breached_count"] = int(stats["breached_count"]) + 1
146
  except Exception as exc:
147
  stats["error_count"] = int(stats["error_count"]) + 1
@@ -174,13 +169,7 @@ class SlaEscalationService:
174
  return False
175
  return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
176
 
177
- def _breach_ticket(
178
- self,
179
- ticket: dict[str, Any],
180
- now: datetime,
181
- *,
182
- background_tasks: BackgroundTasks | None = None,
183
- ) -> None:
184
  ticket_id = str(ticket.get("id"))
185
  company_id = ticket.get("company_id")
186
  escalation_level = int(ticket.get("escalation_level") or 0) + 1
@@ -196,7 +185,6 @@ class SlaEscalationService:
196
 
197
  self._insert_audit_log(ticket, escalation_level, timestamp)
198
  self._emit_system_message(ticket, escalation_level, timestamp)
199
- self._dispatch_breach_alert(ticket, now, background_tasks=background_tasks)
200
 
201
  logger.warning(
202
  "SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
@@ -206,31 +194,6 @@ class SlaEscalationService:
206
  escalation_level,
207
  )
208
 
209
- def _dispatch_breach_alert(
210
- self,
211
- ticket: dict[str, Any],
212
- breach_time: datetime,
213
- *,
214
- background_tasks: BackgroundTasks | None = None,
215
- ) -> None:
216
- ticket_id = str(ticket.get("id") or "")
217
- subject = str(ticket.get("subject") or "Untitled ticket")
218
- category = str(ticket.get("priority") or "Uncategorized")
219
- assignee = str(ticket.get("assigned_team") or "Unassigned")
220
-
221
- if background_tasks is not None:
222
- background_tasks.add_task(
223
- dispatch_slack_alert,
224
- ticket_id,
225
- subject,
226
- category,
227
- assignee,
228
- breach_time,
229
- )
230
- return
231
-
232
- dispatch_slack_alert(ticket_id, subject, category, assignee, breach_time)
233
-
234
  def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
235
  ticket_id = str(ticket.get("id"))
236
  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:
 
110
  now_fn: Callable[[], datetime] = _utc_now,
111
  notification_router: Any = None,
112
  ):
113
+ self.supabase = supabase_client
 
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(