ritesh19180 commited on
Commit
47a644d
·
verified ·
1 Parent(s): 701d404

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 +23 -0
  2. .gitignore +7 -0
  3. Dockerfile +23 -19
  4. Frontend/eslint.config.js +21 -0
  5. Frontend/package-lock.json +0 -0
  6. Frontend/package.json +16 -2
  7. Frontend/src/App.jsx +117 -117
  8. Frontend/src/admin/components/AdminSidebar.jsx +3 -1
  9. Frontend/src/admin/components/SLABadge.jsx +14 -25
  10. Frontend/src/admin/components/TicketTable.jsx +13 -3
  11. Frontend/src/admin/pages/AdminDashboard.jsx +8 -2
  12. Frontend/src/admin/pages/AdminSettings.jsx +111 -2
  13. Frontend/src/admin/pages/AdminTicketDetail.jsx +24 -1
  14. Frontend/src/admin/pages/AdminTickets.jsx +36 -37
  15. Frontend/src/components/shared/BugReportWidget.jsx +0 -12
  16. Frontend/src/config.js +2 -1
  17. Frontend/src/legacy_ui/Dashboard.jsx +2 -2
  18. Frontend/src/pages/AdminSignup.jsx +18 -3
  19. Frontend/src/pages/LandingPage.jsx +44 -41
  20. Frontend/src/pages/Signup.jsx +19 -4
  21. Frontend/src/services/aiAssistant.js +88 -69
  22. Frontend/src/services/api.js +50 -19
  23. Frontend/src/store/authStore.js +59 -2
  24. Frontend/src/store/ticketStore.js +25 -14
  25. Frontend/src/user/components/RecentTickets.jsx +5 -0
  26. Frontend/src/user/pages/AutoResolveChat.jsx +56 -2
  27. Frontend/src/user/pages/CreateTicket.jsx +1 -1
  28. Frontend/src/user/pages/MyTickets.jsx +13 -0
  29. Frontend/src/user/pages/TicketDetail.jsx +26 -0
  30. Frontend/vite.config.js +1 -1
  31. MobileApp/.env.example +2 -0
  32. MobileApp/src/lib/supabase.js +8 -2
  33. MobileApp/src/screens/auth/LoginScreen.js +2 -0
  34. MobileApp/src/screens/user/ProfileScreen.js +3 -0
  35. README.md +3 -1
  36. backend/.env.example +101 -13
  37. backend/Dockerfile +23 -19
  38. backend/auth/crypto.py +208 -88
  39. backend/main.py +666 -315
  40. backend/requirements.txt +8 -1
  41. backend/services/classifier_service.py +80 -103
  42. backend/services/classifier_v2.py +88 -68
  43. backend/services/classifier_v3.py +78 -58
  44. backend/services/duplicate_service.py +27 -2
  45. backend/services/gemini_service.py +63 -0
  46. backend/services/ner_service.py +16 -4
  47. backend/services/rag_service.py +17 -1
  48. backend/services/sla_service.py +41 -4
  49. backend/services/slack_notifier.py +2 -2
  50. backend/tests/conftest.py +1 -0
.gitattributes CHANGED
@@ -2,6 +2,29 @@
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
 
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
.gitignore CHANGED
@@ -34,3 +34,10 @@ docs/logs/
34
  scratch/
35
  .npm-cache/
36
 
 
 
 
 
 
 
 
 
34
  scratch/
35
  .npm-cache/
36
 
37
+ # GSSoC Score Tracking and Temporary Scripts
38
+ scratch/
39
+ .npm-cache/
40
+ *.tmp
41
+ ../MOBILE_SETUP_TEMP.md
42
+ ../CI_YML_TEMP.yml
43
+ ../ENV_EXAMPLE_TEMP.example
Dockerfile CHANGED
@@ -1,36 +1,40 @@
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"]
 
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"]
Frontend/eslint.config.js CHANGED
@@ -35,4 +35,25 @@ export default defineConfig([
35
  'react-refresh/only-export-components': 'off',
36
  },
37
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ])
 
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
  ])
Frontend/package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
Frontend/package.json CHANGED
@@ -7,9 +7,11 @@
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,11 +44,18 @@
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,11 +63,16 @@
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
  }
 
7
  "dev": "vite",
8
  "build": "vite build",
9
  "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10
+ "check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
11
  "preview": "vite preview",
12
  "prepare": "husky install",
13
+ "format": "prettier --write .",
14
+ "test": "jest"
15
  },
16
  "dependencies": {
17
  "@google/generative-ai": "^0.24.1",
 
44
  "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
  "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
  }
Frontend/src/App.jsx CHANGED
@@ -5,80 +5,81 @@ import {
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,6 +97,7 @@ 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,10 +193,11 @@ function AppLayout() {
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,63 +209,60 @@ function App() {
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
  }
 
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
  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
  <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
 
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
  }
Frontend/src/admin/components/AdminSidebar.jsx CHANGED
@@ -10,7 +10,8 @@ import {
10
  LogOut,
11
  Activity,
12
  ChevronLeft,
13
- ChevronRight
 
14
  } from 'lucide-react';
15
  import useAuthStore from '../../store/authStore';
16
 
@@ -21,6 +22,7 @@ const AdminSidebar = ({ isMobile, onClose, isCollapsed, onToggleCollapse }) => {
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();
 
10
  LogOut,
11
  Activity,
12
  ChevronLeft,
13
+ ChevronRight,
14
+ Clock
15
  } from 'lucide-react';
16
  import useAuthStore from '../../store/authStore';
17
 
 
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();
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 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,47 +19,36 @@ 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 (
 
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
  }
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 (
Frontend/src/admin/components/TicketTable.jsx CHANGED
@@ -73,6 +73,9 @@ 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
 
77
  // Truncated subject
78
  const subject = ticket.subject || ticket.summary || 'Untitled ticket';
@@ -129,9 +132,16 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
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>
 
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
  <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>
Frontend/src/admin/pages/AdminDashboard.jsx CHANGED
@@ -4,6 +4,7 @@ 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";
@@ -107,6 +108,13 @@ const AdminDashboard = () => {
107
  const [isLoading, setIsLoading] = React.useState(true);
108
  const [nowMs, setNowMs] = React.useState(() => Date.now());
109
 
 
 
 
 
 
 
 
110
  React.useEffect(() => {
111
  if (profile) {
112
  const fetchStats = async () => {
@@ -135,8 +143,6 @@ const AdminDashboard = () => {
135
  };
136
 
137
  fetchStats();
138
- const interval = setInterval(fetchStats, 30000);
139
- return () => clearInterval(interval);
140
  }
141
  }, [profile]);
142
 
 
4
 
5
  import useAuthStore from "../../store/authStore";
6
  import { supabase } from "../../lib/supabaseClient";
7
+ import useTicketsRealtime from "../../hooks/useTicketsRealtime";
8
  import StatCard from "../components/StatCard";
9
  import TicketTable from "../components/TicketTable";
10
  import { formatTimelineDate } from "../../utils/dateUtils";
 
108
  const [isLoading, setIsLoading] = React.useState(true);
109
  const [nowMs, setNowMs] = React.useState(() => Date.now());
110
 
111
+ useTicketsRealtime({
112
+ company: profile?.company,
113
+ enabled: Boolean(profile),
114
+ onTicketsChange: setTickets,
115
+ channelName: 'admin_dashboard_tickets_realtime',
116
+ });
117
+
118
  React.useEffect(() => {
119
  if (profile) {
120
  const fetchStats = async () => {
 
143
  };
144
 
145
  fetchStats();
 
 
146
  }
147
  }, [profile]);
148
 
Frontend/src/admin/pages/AdminSettings.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React from 'react';
2
  import {
3
  Settings,
4
  Cpu,
@@ -8,6 +8,14 @@ import {
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,12 +25,97 @@ import { Select } from "../../components/ui/select";
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,6 +128,22 @@ const AdminSettings = () => {
35
  <ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
36
  </p>
37
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  </div>
39
 
40
  <div className="space-y-8">
 
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
  import {
3
  Settings,
4
  Cpu,
 
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
  */
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
  <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">
Frontend/src/admin/pages/AdminTicketDetail.jsx CHANGED
@@ -16,6 +16,7 @@ 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
 
20
  const AdminTicketDetail = () => {
21
  const { ticket_id } = useParams();
@@ -34,6 +35,7 @@ const AdminTicketDetail = () => {
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,6 +199,11 @@ const AdminTicketDetail = () => {
197
  const displayPriority = ticket.priority || 'Medium';
198
  const displaySummary = ticket.summary || ticket.subject || 'No Summary';
199
  const displayText = ticket.description || ticket.text || displaySummary;
 
 
 
 
 
200
 
201
  return (
202
  <div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
@@ -312,8 +319,22 @@ const AdminTicketDetail = () => {
312
  <span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
313
  </div>
314
  <div style={{ padding: '28px' }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  <div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
316
- "{displayText}"
317
  </div>
318
 
319
  {imageUrl && (
@@ -342,6 +363,8 @@ const AdminTicketDetail = () => {
342
  </div>
343
  <TicketTimeline ticket={ticket} />
344
  </div>
 
 
345
  </div>
346
 
347
  {/* AI Column */}
 
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
  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
  const displayPriority = ticket.priority || 'Medium';
200
  const displaySummary = ticket.summary || ticket.subject || 'No Summary';
201
  const displayText = ticket.description || ticket.text || displaySummary;
202
+ const translationMeta = ticket.metadata?.translation;
203
+ const originalTextMeta = ticket.metadata?.original_text;
204
+ const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
205
+ const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
206
+ const renderedText = showOriginalText && isTranslated ? originalTextMeta.description : displayText;
207
 
208
  return (
209
  <div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
 
319
  <span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
320
  </div>
321
  <div style={{ padding: '28px' }}>
322
+ {isTranslated && (
323
+ <div style={{ marginBottom: '16px', border: '1px solid #bae6fd', background: '#f0f9ff', borderRadius: '12px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
324
+ <span style={{ fontSize: '12px', fontWeight: 600, color: '#0c4a6e' }}>
325
+ Translated from {sourceLanguageName}
326
+ </span>
327
+ <button
328
+ type="button"
329
+ onClick={() => setShowOriginalText(prev => !prev)}
330
+ style={{ fontSize: '11px', fontWeight: 700, color: '#0369a1', background: 'transparent', border: 'none', cursor: 'pointer' }}
331
+ >
332
+ {showOriginalText ? 'View English' : 'View Original'}
333
+ </button>
334
+ </div>
335
+ )}
336
  <div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
337
+ "{renderedText}"
338
  </div>
339
 
340
  {imageUrl && (
 
363
  </div>
364
  <TicketTimeline ticket={ticket} />
365
  </div>
366
+
367
+ <TicketAuditTimeline ticketId={ticket.id} companyId={ticket.company_id} />
368
  </div>
369
 
370
  {/* AI Column */}
Frontend/src/admin/pages/AdminTickets.jsx CHANGED
@@ -1,8 +1,9 @@
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,7 +30,7 @@ import { formatTimelineDate } from "../../utils/dateUtils";
29
  const AdminTickets = () => {
30
  const navigate = useNavigate();
31
  const location = useLocation();
32
- const { user } = useAuthStore();
33
  const { showToast } = useToastStore();
34
 
35
  // Data State
@@ -46,6 +47,27 @@ const AdminTickets = () => {
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,38 +135,6 @@ const AdminTickets = () => {
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,8 +295,11 @@ const AdminTickets = () => {
305
  </tr>
306
  </thead>
307
  <tbody className="divide-y divide-slate-50">
308
- {filteredTickets.map((ticket) => (
309
- <tr key={ticket.id} className={`hover:bg-slate-50/50 transition-colors group ${isUpdating === ticket.id ? 'opacity-50 pointer-events-none' : ''}`}>
 
 
 
310
  {/* Ticket ID */}
311
  <td className="px-6 py-6">
312
  <span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
@@ -347,6 +340,11 @@ const AdminTickets = () => {
347
  {ticket.category}
348
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
349
  </span>
 
 
 
 
 
350
  </div>
351
  </td>
352
 
@@ -456,7 +454,8 @@ const AdminTickets = () => {
456
  </div>
457
  </td>
458
  </tr>
459
- ))}
 
460
  </tbody>
461
  </table>
462
  </div>
 
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
  const AdminTickets = () => {
31
  const navigate = useNavigate();
32
  const location = useLocation();
33
+ const { user, profile } = useAuthStore();
34
  const { showToast } = useToastStore();
35
 
36
  // Data State
 
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
 
136
  useEffect(() => {
137
  fetchInitialData();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }, [statusFilter, categoryFilter, priorityFilter, teamFilter]);
139
 
140
  // Seed search from URL
 
295
  </tr>
296
  </thead>
297
  <tbody className="divide-y divide-slate-50">
298
+ {filteredTickets.map((ticket) => {
299
+ const wasLiveChanged = String(lastChangedTicketId) === String(ticket.id);
300
+
301
+ return (
302
+ <tr key={ticket.id} className={`hover:bg-slate-50/50 transition-colors group ${wasLiveChanged ? 'bg-emerald-50/70 ring-1 ring-emerald-100' : ''} ${isUpdating === ticket.id ? 'opacity-50 pointer-events-none' : ''}`}>
303
  {/* Ticket ID */}
304
  <td className="px-6 py-6">
305
  <span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
 
340
  {ticket.category}
341
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
342
  </span>
343
+ {ticket?.metadata?.translation?.translated && (
344
+ <span className="text-[10px] text-sky-700 mt-1">
345
+ Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
346
+ </span>
347
+ )}
348
  </div>
349
  </td>
350
 
 
454
  </div>
455
  </td>
456
  </tr>
457
+ );
458
+ })}
459
  </tbody>
460
  </table>
461
  </div>
Frontend/src/components/shared/BugReportWidget.jsx CHANGED
@@ -23,7 +23,6 @@ function useDiagnostics() {
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,16 +30,6 @@ function useDiagnostics() {
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,7 +40,6 @@ function useDiagnostics() {
51
  window.addEventListener('error', handleError);
52
 
53
  return () => {
54
- console.error = originalConsoleError;
55
  window.removeEventListener('error', handleError);
56
  };
57
  }, []);
 
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
  screen: screenInfo
31
  }));
32
 
 
 
 
 
 
 
 
 
 
 
33
  // Global Error Listener
34
  const handleError = (e) => {
35
  setDiagnostics(prev => ({
 
40
  window.addEventListener('error', handleError);
41
 
42
  return () => {
 
43
  window.removeEventListener('error', handleError);
44
  };
45
  }, []);
Frontend/src/config.js CHANGED
@@ -13,5 +13,6 @@ 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
  };
 
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
  };
Frontend/src/legacy_ui/Dashboard.jsx CHANGED
@@ -63,7 +63,7 @@ const Dashboard = () => {
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,7 +84,7 @@ const Dashboard = () => {
84
  });
85
 
86
  prevTicketsRef.current = tickets;
87
- // eslint-disable-next-line react-hooks/exhaustive-deps
88
  }, [tickets]);
89
 
90
  // Summary Counts
 
63
  }
64
  };
65
  fetchTickets();
66
+
67
  }, []);
68
 
69
  // Detect newly inserted or updated tickets for highlight animation
 
84
  });
85
 
86
  prevTicketsRef.current = tickets;
87
+
88
  }, [tickets]);
89
 
90
  // Summary Counts
Frontend/src/pages/AdminSignup.jsx CHANGED
@@ -10,6 +10,7 @@ import {
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,6 +45,14 @@ function AdminSignup() {
44
 
45
  const navigate = useNavigate();
46
  const { signup, loading, user, profile } = useAuthStore();
 
 
 
 
 
 
 
 
47
 
48
  // Redirect if already logged in and verified
49
  useEffect(() => {
@@ -80,8 +89,8 @@ function AdminSignup() {
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,7 +431,7 @@ function AdminSignup() {
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,6 +443,12 @@ function AdminSignup() {
434
  animate={{ width: `${passwordStrength}%` }}
435
  />
436
  </div>
 
 
 
 
 
 
437
  </div>
438
  )}
439
  </div>
 
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
 
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
  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
  </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
  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>
Frontend/src/pages/LandingPage.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useRef, useEffect, useState } from 'react';
2
 
3
  import { motion, AnimatePresence } from 'framer-motion';
4
  import { useNavigate } from 'react-router-dom';
@@ -14,6 +14,12 @@ import {
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,9 +130,9 @@ function DemoModal({ onClose }) {
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,7 +151,7 @@ export default function LandingPage() {
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,7 +214,7 @@ export default function LandingPage() {
208
  </div>
209
  )
210
  }
211
- ];
212
 
213
  useEffect(() => {
214
  if (!loading && user && profile) {
@@ -225,7 +231,7 @@ export default function LandingPage() {
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,7 +241,7 @@ export default function LandingPage() {
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,7 +251,7 @@ export default function LandingPage() {
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,13 +322,13 @@ export default function LandingPage() {
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,13 +351,13 @@ export default function LandingPage() {
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,15 +388,15 @@ export default function LandingPage() {
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,7 +433,7 @@ export default function LandingPage() {
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,7 +664,7 @@ export default function LandingPage() {
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,28 +675,25 @@ export default function LandingPage() {
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,14 +704,14 @@ export default function LandingPage() {
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,7 +775,7 @@ export default function LandingPage() {
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,15 +818,15 @@ export default function LandingPage() {
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">
 
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
  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
  <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
  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
  </div>
215
  )
216
  }
217
+ ], []);
218
 
219
  useEffect(() => {
220
  if (!loading && user && profile) {
 
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
  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
  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
  </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
  <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
  <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
  <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
  <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
  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
  {/* 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
  <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
  <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">
Frontend/src/pages/Signup.jsx CHANGED
@@ -3,6 +3,7 @@ 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
 
7
  function Signup() {
8
  const [email, setEmail] = useState("");
@@ -27,6 +28,10 @@ function Signup() {
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,13 +118,13 @@ function Signup() {
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,6 +326,11 @@ function Signup() {
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,6 +341,11 @@ function Signup() {
331
  {showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
332
  </button>
333
  </div>
 
 
 
 
 
334
  </div>
335
  </div>
336
 
 
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
  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
  return;
119
  }
120
 
121
+ if (passwordWarning) {
122
+ setError(passwordWarning);
123
  return;
124
  }
125
 
126
+ if (confirmPasswordWarning) {
127
+ setError(confirmPasswordWarning);
128
  return;
129
  }
130
 
 
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
  {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
 
Frontend/src/services/aiAssistant.js CHANGED
@@ -1,5 +1,4 @@
1
- import { GoogleGenerativeAI } from "@google/generative-ai";
2
- import { API_CONFIG } from "../config";
3
 
4
  // ============================================================
5
  // MULTI-API FAILOVER CONFIGURATION
@@ -8,45 +7,30 @@ import { API_CONFIG } from "../config";
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,11 +41,8 @@ const buildConfigList = () => {
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,72 +51,110 @@ const callGemini = async (config, promptText, history, image) => {
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')
 
1
+ import { supabase } from "../lib/supabaseClient";
 
2
 
3
  // ============================================================
4
  // MULTI-API FAILOVER CONFIGURATION
 
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
  // 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
  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')
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 = true;
6
  const API_BASE_URL = API_CONFIG.BACKEND_URL;
7
 
8
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -38,38 +38,69 @@ const setStorage = (key, data) => {
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
 
 
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
  }
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
 
Frontend/src/store/authStore.js CHANGED
@@ -1,8 +1,40 @@
1
  import { create } from 'zustand';
2
  import { persist } from 'zustand/middleware';
3
  import { supabase } from '../lib/supabaseClient';
 
4
  import useTicketStore from './ticketStore';
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  let currentUserPromise = null;
7
 
8
  const useAuthStore = create(
@@ -89,6 +121,13 @@ const useAuthStore = create(
89
  currentUserPromise = (async () => {
90
  try {
91
  set({ isCheckingSession: true });
 
 
 
 
 
 
 
92
  const { data: { user }, error } = await supabase.auth.getUser();
93
  if (error) throw error;
94
 
@@ -117,6 +156,8 @@ const useAuthStore = create(
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,6 +246,14 @@ const useAuthStore = create(
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,6 +294,15 @@ const useAuthStore = create(
245
  logout: async () => {
246
  set({ loading: true });
247
  try {
 
 
 
 
 
 
 
 
 
248
  const { error } = await supabase.auth.signOut();
249
  if (error) throw error;
250
  set({ user: null, profile: null });
@@ -305,8 +363,7 @@ const useAuthStore = create(
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
  }
 
1
  import { create } from 'zustand';
2
  import { persist } from 'zustand/middleware';
3
  import { supabase } from '../lib/supabaseClient';
4
+ import { API_CONFIG } from '../config';
5
  import useTicketStore from './ticketStore';
6
 
7
+ const BACKEND_URL = API_CONFIG.BACKEND_URL;
8
+
9
+ const verifyServerCookieSession = async () => {
10
+ try {
11
+ const res = await fetch(`${BACKEND_URL}/auth/me`, {
12
+ method: 'GET',
13
+ credentials: 'include',
14
+ headers: { Accept: 'application/json' },
15
+ });
16
+ if (!res.ok) return null;
17
+ const body = await res.json();
18
+ return body?.user || null;
19
+ } catch (e) {
20
+ console.warn('Server cookie session check failed:', e?.message || e);
21
+ return null;
22
+ }
23
+ };
24
+
25
+ const mirrorBackendAuth = async (path, payload) => {
26
+ try {
27
+ await fetch(`${BACKEND_URL}${path}`, {
28
+ method: 'POST',
29
+ credentials: 'include',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify(payload),
32
+ });
33
+ } catch (e) {
34
+ console.warn(`Backend auth ${path} failed:`, e?.message || e);
35
+ }
36
+ };
37
+
38
  let currentUserPromise = null;
39
 
40
  const useAuthStore = create(
 
121
  currentUserPromise = (async () => {
122
  try {
123
  set({ isCheckingSession: true });
124
+ const cookieUser = await verifyServerCookieSession();
125
+ if (cookieUser) {
126
+ set({ user: cookieUser });
127
+ get().getProfile(cookieUser);
128
+ return cookieUser;
129
+ }
130
+
131
  const { data: { user }, error } = await supabase.auth.getUser();
132
  if (error) throw error;
133
 
 
156
  set({ loading: true });
157
  console.log("Attempting login for:", email);
158
  try {
159
+ await mirrorBackendAuth('/auth/login', { email, password });
160
+
161
  const { data, error } = await supabase.auth.signInWithPassword({
162
  email,
163
  password,
 
246
  console.log("Starting signup for:", email);
247
 
248
  try {
249
+ await mirrorBackendAuth('/auth/signup', {
250
+ email,
251
+ password,
252
+ full_name: fullName,
253
+ role,
254
+ company,
255
+ });
256
+
257
  // 1. Auth Signup with Metadata
258
  console.log("Step 1: Auth.signUp...");
259
  const { data, error } = await supabase.auth.signUp({
 
294
  logout: async () => {
295
  set({ loading: true });
296
  try {
297
+ try {
298
+ await fetch(`${BACKEND_URL}/auth/logout`, {
299
+ method: 'POST',
300
+ credentials: 'include',
301
+ });
302
+ } catch (e) {
303
+ console.warn('Backend cookie logout failed:', e?.message || e);
304
+ }
305
+
306
  const { error } = await supabase.auth.signOut();
307
  if (error) throw error;
308
  set({ user: null, profile: null });
 
363
  {
364
  name: 'auth-storage',
365
  partialize: (state) => ({
366
+ // Profile only JWT session lives in HttpOnly cookies (issue #130)
 
367
  profile: state.profile
368
  }),
369
  }
Frontend/src/store/ticketStore.js CHANGED
@@ -27,30 +27,41 @@ const useTicketStore = create(
27
  })),
28
  addTicket: (ticket) => set((state) => {
29
  return {
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
 
27
  })),
28
  addTicket: (ticket) => set((state) => {
29
  return {
30
+ tickets: [ticket, ...state.tickets]
31
  };
32
  }),
33
+ upsertTicket: (ticket) => set((state) => {
34
+ const ticketId = ticket?.id ?? ticket?.ticket_id;
35
+ if (!ticketId) return state;
 
 
 
36
 
37
+ const exists = state.tickets.some(t => (t.id ?? t.ticket_id) === ticketId);
38
+ const tickets = exists
39
+ ? state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...ticket } : t)
40
+ : [ticket, ...state.tickets];
41
+ const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
42
 
43
+ return {
44
+ tickets,
45
+ activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...ticket } : state.activeTicket
46
+ };
47
+ }),
48
+ removeTicket: (ticketId) => set((state) => ({
49
+ tickets: state.tickets.filter(t => (t.id ?? t.ticket_id) !== ticketId),
50
+ activeTicket: (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId
51
+ ? null
52
+ : state.activeTicket
53
+ })),
54
+ updateTicket: (ticketId, updates) => set((state) => {
55
+ // eslint-disable-next-line no-unused-vars
56
+ const existingTicket = state.tickets.find(t => (t.id ?? t.ticket_id) === ticketId);
57
+ const updatedTickets = state.tickets.map(t => ((t.id ?? t.ticket_id) === ticketId) ? { ...t, ...updates } : t);
58
+ const shouldUpdateActive = ((state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId);
59
 
60
  return {
61
  tickets: updatedTickets,
62
  activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
63
  };
64
  }),
 
 
 
 
 
 
 
65
  appendMessage: (ticketId, message) => set((state) => {
66
  const updatedTickets = state.tickets.map(t =>
67
  t.ticket_id === ticketId
Frontend/src/user/components/RecentTickets.jsx CHANGED
@@ -150,6 +150,11 @@ 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
  </td>
154
  <td style={{ padding: '16px 28px' }}>
155
  {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
+ {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)}
Frontend/src/user/pages/AutoResolveChat.jsx CHANGED
@@ -19,6 +19,7 @@ const AutoResolveChat = () => {
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,7 +78,7 @@ const AutoResolveChat = () => {
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,7 +88,12 @@ const AutoResolveChat = () => {
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,6 +203,12 @@ const AutoResolveChat = () => {
197
  else recognition.start();
198
  };
199
 
 
 
 
 
 
 
200
  if (!aiTicket) return null;
201
 
202
  return (
@@ -249,6 +261,48 @@ const AutoResolveChat = () => {
249
  </div>
250
  </div>
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  {/* Chat Messages */}
253
  <div
254
  ref={scrollRef}
 
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
  }
79
 
80
  if (newSteps.length >= 2) {
81
+ setSteps(newSteps);
82
  } else {
83
  const sentences = response
84
  .replace(/\*\*/g, '')
 
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
  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
  </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}
Frontend/src/user/pages/CreateTicket.jsx CHANGED
@@ -21,7 +21,6 @@ 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 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,6 +110,7 @@ const CreateTicket = () => {
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) {
 
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
  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) {
Frontend/src/user/pages/MyTickets.jsx CHANGED
@@ -120,6 +120,14 @@ function MyTickets() {
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,6 +325,11 @@ function MyTickets() {
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">
 
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
  <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">
Frontend/src/user/pages/TicketDetail.jsx CHANGED
@@ -22,6 +22,7 @@ const TicketDetail = () => {
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,6 +123,10 @@ const TicketDetail = () => {
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,6 +189,27 @@ const TicketDetail = () => {
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">
 
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
  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
 
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">
Frontend/vite.config.js CHANGED
@@ -15,6 +15,6 @@ export default defineConfig({
15
  },
16
  },
17
  build: {
18
- sourcemap: true
19
  }
20
  })
 
15
  },
16
  },
17
  build: {
18
+ sourcemap: 'hidden'
19
  }
20
  })
MobileApp/.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ EXPO_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
2
+ EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
MobileApp/src/lib/supabase.js CHANGED
@@ -2,8 +2,14 @@ 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 = 'https://aejuenhqciagpntcqoir.supabase.co';
6
- const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFlanVlbmhxY2lhZ3BudGNxb2lyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIzODQwNzgsImV4cCI6MjA4Nzk2MDA3OH0.-OxgEW5t4alPGlzV_JZDRZcLsQbbMap6jiWjfAVkMMY';
 
 
 
 
 
 
7
 
8
  export const supabase = createClient(supabaseUrl, supabaseKey, {
9
  auth: {
 
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: {
MobileApp/src/screens/auth/LoginScreen.js CHANGED
@@ -5,6 +5,7 @@ import {
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,6 +53,7 @@ const LoginScreen = () => {
52
  }
53
  setLoading(true);
54
  try {
 
55
  const { data, error } = await supabase.auth.signInWithPassword({ email, password });
56
  if (error) throw error;
57
 
 
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
  }
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
 
MobileApp/src/screens/user/ProfileScreen.js CHANGED
@@ -5,6 +5,7 @@ import {
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,6 +227,7 @@ const ProfileScreen = () => {
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,6 +238,7 @@ const ProfileScreen = () => {
236
  } catch (e) {
237
  console.warn("Logout error, forcing full wipe:", e);
238
  await AsyncStorage.clear();
 
239
  }
240
  };
241
 
 
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
  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
  } catch (e) {
239
  console.warn("Logout error, forcing full wipe:", e);
240
  await AsyncStorage.clear();
241
+ await supabase.auth.signOut();
242
  }
243
  };
244
 
README.md CHANGED
@@ -58,7 +58,9 @@ 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 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
 
 
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
 
backend/.env.example CHANGED
@@ -1,18 +1,106 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
backend/Dockerfile CHANGED
@@ -1,36 +1,40 @@
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"]
 
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"]
backend/auth/crypto.py CHANGED
@@ -1,98 +1,218 @@
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}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
backend/main.py CHANGED
@@ -8,6 +8,7 @@ import os
8
  import sys
9
  import uuid
10
  import json
 
11
  import datetime
12
  import traceback
13
  import warnings
@@ -19,9 +20,8 @@ from contextlib import asynccontextmanager
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
@@ -36,13 +36,12 @@ from dotenv import load_dotenv
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,7 +52,8 @@ 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,16 +65,14 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")
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
 
79
 
80
  # ---------------------------------------------------------------------------
@@ -123,13 +121,62 @@ def detect_semantic_duplicate(text: str, *, company_id: str | None, threshold: f
123
  duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
124
  duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
125
  return duplicate_result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  class TicketRequest(BaseModel):
127
  text: str
128
  image_base64: str = ""
129
  image_text: str = "" # Keep for backward compatibility
130
  user_id: str | None = None
131
  company: str | None = None
132
- company_id: str | None = None
133
  image_url: str | None = None
134
  confidence_threshold: float = 0.20
135
  duplicate_sensitivity: float = 0.85
@@ -149,10 +196,6 @@ class TicketSaveRequest(BaseModel):
149
  image_url: str | None = None
150
  company: str | None = None
151
  company_id: str | None = None
152
- description_vector: list[float] | None = None
153
- is_potential_duplicate: bool = False
154
- parent_ticket_id: str | None = None
155
- sla_response_due_at: str | None = None
156
  sla_breach_at: str
157
  sla_status: str | None = None
158
  escalation_level: int = 0
@@ -168,8 +211,6 @@ class TicketSaveRequest(BaseModel):
168
  class DuplicateInfo(BaseModel):
169
  is_duplicate: bool
170
  duplicate_ticket_id: str | None = None
171
- parent_ticket_id: str | None = None
172
- is_potential_duplicate: bool = False
173
  similarity: float = 0.0
174
 
175
 
@@ -191,8 +232,6 @@ class TicketResponse(BaseModel):
191
  entities: list[EntityInfo]
192
  duplicate_ticket: DuplicateInfo
193
  confidence: float
194
- is_potential_duplicate: bool = False
195
- parent_ticket_id: str | None = None
196
  needs_review: bool = False
197
  reasoning: str = ""
198
  decision_factors: list[str] = []
@@ -202,6 +241,10 @@ class TicketResponse(BaseModel):
202
  timeline: dict = {} # Map of step_name: timestamp
203
  env_metadata: dict = {} # IP, Hostname, Browser/OS
204
  sla_breach_at: str | None = None
 
 
 
 
205
  version: str = "2.1.0-Neural-Diagnostic"
206
 
207
 
@@ -229,6 +272,24 @@ class TicketRecord(BaseModel):
229
  timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
230
 
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  # --- In-Memory Database (to be replaced with SQL later) ---
233
  TICKETS_DB: list[TicketRecord] = []
234
 
@@ -247,10 +308,14 @@ class ReadinessResponse(BaseModel):
247
  # ---------------------------------------------------------------------------
248
  # Service singletons
249
  # ---------------------------------------------------------------------------
 
 
250
  classifier_service = ClassifierService()
251
  ner_service = NERService()
252
  duplicate_service = DuplicateService()
253
  rag_service = RagService()
 
 
254
 
255
  try:
256
  from backend.services.gemini_service import GeminiService
@@ -264,6 +329,82 @@ try:
264
  except ImportError:
265
  ocr_service = None
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
  # ---------------------------------------------------------------------------
269
  # Lifespan (startup / shutdown)
@@ -273,12 +414,16 @@ async def lifespan(app: FastAPI):
273
  """Load all models at startup."""
274
  print("[Startup] Loading AI models ...")
275
  try:
276
- classifier_service.load()
277
  except Exception as e:
 
 
 
 
278
  print(f"[WARNING] Classifier not loaded: {e}")
279
  try:
280
  ner_service.load()
281
- except Exception as e:
282
  print(f"[WARNING] NER not loaded: {e}")
283
  try:
284
  duplicate_service.load()
@@ -288,50 +433,39 @@ async def lifespan(app: FastAPI):
288
  rag_service.load()
289
  except Exception as e:
290
  print(f"[WARNING] RAG service not loaded: {e}")
 
 
 
 
291
 
292
  if gemini_service:
293
  print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
294
  else:
295
  print("[Startup] Gemini Service: NOT LOADED (Import failed)")
296
 
297
- print("[Startup] Classifier V2 Shadow: Ready.")
298
- print("[Startup] Ready.")
299
- # Strict health checks: fail loudly when core model assets are unavailable.
300
- # Set ALLOW_DEGRADED_STARTUP=1 to permit degraded startup for local/dev convenience.
301
- try:
302
- strict_mode = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") != "1"
303
- except Exception:
304
- strict_mode = True
305
 
306
- classifier_loaded_flag = getattr(classifier_service, "_loaded", False)
307
- ner_loaded_flag = getattr(ner_service, "_loaded", False)
 
 
 
 
 
308
 
309
- if strict_mode and not classifier_loaded_flag:
310
- raise RuntimeError("[Startup-FATAL] Classifier assets not loaded. Set ALLOW_DEGRADED_STARTUP=1 to bypass.")
 
 
 
311
 
312
- sla_task = None
313
- try:
314
- if supabase and os.environ.get("SLA_ESCALATION_ENABLED", "true").lower() == "true":
315
- notification_router = None
316
- try:
317
- from backend.services.notification_routing import load as load_notification_router
318
- notification_router = load_notification_router()
319
- except Exception as e:
320
- print(f"[WARNING] Notification router not loaded for SLA service: {e}")
321
- sla_service = load_sla_service(supabase, notification_router)
322
- interval = int(os.environ.get("SLA_ESCALATION_INTERVAL_SECONDS", "300"))
323
- sla_task = asyncio.create_task(run_sla_escalation_loop(sla_service, interval_seconds=interval))
324
- print(f"[Startup] SLA escalation loop enabled ({interval}s interval).")
325
-
326
- yield
327
- finally:
328
- if sla_task:
329
- sla_task.cancel()
330
- try:
331
- await sla_task
332
- except asyncio.CancelledError:
333
- pass
334
- print("[Shutdown] Cleaning up ...")
335
 
336
 
337
  # ---------------------------------------------------------------------------
@@ -362,6 +496,8 @@ app.add_middleware(
362
  allow_headers=["*"],
363
  )
364
 
 
 
365
 
366
  # ---------------------------------------------------------------------------
367
  # Root & Health check
@@ -448,17 +584,6 @@ async def root():
448
  """
449
 
450
 
451
- async def verify_metrics_token(x_metrics_token: str | None = Header(default=None)):
452
- expected_token = os.environ.get("METRICS_TOKEN")
453
- if expected_token and x_metrics_token != expected_token:
454
- raise HTTPException(status_code=403, detail="Forbidden")
455
-
456
-
457
- @app.get("/metrics", dependencies=[Depends(verify_metrics_token)])
458
- def metrics():
459
- return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
460
-
461
-
462
  @app.get("/health", response_model=HealthResponse)
463
  async def health_check():
464
  return HealthResponse(
@@ -471,29 +596,18 @@ async def health_check():
471
  @app.get("/ready", response_model=ReadinessResponse)
472
  async def readiness_check():
473
  require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
474
- allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
475
-
476
  checks = {
477
  "api": True,
478
  "classifier_loaded": classifier_service._loaded,
479
  "ner_loaded": ner_service._loaded,
480
- "duplicate_index_loaded": duplicate_service.is_available(),
481
- "rag_loaded": rag_service.is_available(),
482
  }
483
  if require_supabase:
484
  checks["supabase_configured"] = supabase is not None
485
 
486
- # In degraded mode, duplicate and RAG services are optional
487
- if allow_degraded:
488
- required_checks = {k: v for k, v in checks.items() if k not in ["duplicate_index_loaded", "rag_loaded"]}
489
- all_required_pass = all(required_checks.values())
490
-
491
- if all_required_pass:
492
- return ReadinessResponse(status="ready", checks=checks)
493
- else:
494
- # Strict mode: all checks must pass
495
- if all(checks.values()):
496
- return ReadinessResponse(status="ready", checks=checks)
497
 
498
  return JSONResponse(
499
  status_code=503,
@@ -634,31 +748,6 @@ async def get_tickets(company_id: str | None = None):
634
  res = query.execute()
635
  return res.data
636
 
637
- @app.get("/tickets/search")
638
- async def search_tickets(q: str | None = None, company_id: str | None = None, limit: int = 50, offset: int = 0):
639
- """Search tickets using tenant-safe full-text search."""
640
- if not supabase:
641
- raise HTTPException(status_code=500, detail="Database connection not initialized")
642
-
643
- if not q:
644
- raise HTTPException(status_code=400, detail="Search query is required")
645
- if not company_id:
646
- raise HTTPException(status_code=400, detail="company_id is required for tenant-safe search")
647
-
648
- try:
649
- result = supabase.rpc(
650
- "search_tickets",
651
- {
652
- "query_text": q,
653
- "company_id": company_id,
654
- "limit_rows": limit,
655
- "offset_rows": offset,
656
- },
657
- ).execute()
658
- return result.data or []
659
- except Exception as e:
660
- raise HTTPException(status_code=500, detail=f"Search failed: {e}")
661
-
662
  @app.post("/tickets/save")
663
  async def save_ticket(request_body: TicketSaveRequest):
664
  """
@@ -669,64 +758,80 @@ async def save_ticket(request_body: TicketSaveRequest):
669
  raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
670
 
671
  logger = logging.getLogger(__name__)
672
- try:
673
- final_data = request_body.dict()
674
-
675
- # Resolve tenant linkage from user profile with authorization validation.
676
- profile = {}
677
- if request_body.user_id:
678
- try:
679
- profile_res = (
680
- supabase.table("profiles")
681
- .select("company_id, company")
682
- .eq("id", request_body.user_id)
683
- .single()
684
- .execute()
685
- )
686
- profile = profile_res.data or {}
687
- if not profile:
688
- raise HTTPException(status_code=404, detail="User profile not found")
689
-
690
- # SELF-HEALING: If company_id is null in database but company name exists, resolve it!
691
- if not profile.get("company_id") and profile.get("company"):
692
- try:
693
- comp_name = profile.get("company").strip()
694
- comp_res = (
695
- supabase.table("companies")
696
- .select("id")
697
- .ilike("name", comp_name)
698
- .execute()
699
- )
700
- if comp_res.data:
701
- resolved_company_id = comp_res.data[0]["id"]
702
- # Backfill the profile table in real-time
703
- supabase.table("profiles").update({"company_id": resolved_company_id}).eq("id", request_body.user_id).execute()
704
- profile["company_id"] = resolved_company_id
705
- logger.info(f"[SELF-HEALING] Backfilled company_id={resolved_company_id} for user={request_body.user_id}")
706
- except Exception as healing_err:
707
- logger.warning(f"[SELF-HEALING WARNING] Failed to backfill company_id: {healing_err}")
708
- except HTTPException:
709
- raise
710
- except Exception as profile_error:
711
- user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
712
- logger.error(f"Tenant resolution error for user {user_hash}: {profile_error}")
713
- raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
714
-
715
- # Validate tenant consistency and authorization.
716
- profile_company_id = profile.get("company_id")
717
- if final_data.get("company_id"):
718
- # User provided company_id: verify it matches their profile.
719
- if profile_company_id and final_data["company_id"] != profile_company_id:
720
- user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
721
- logger.warning(f"Tenant mismatch: user {user_hash} attempted {final_data['company_id']}, assigned to {profile_company_id}")
722
- raise HTTPException(status_code=403, detail="User not authorized for this tenant")
723
- elif profile_company_id:
724
- # Backfill company_id from profile.
725
- final_data["company_id"] = profile_company_id
726
- elif request_body.user_id:
727
- # User has no tenant assignment.
728
- raise HTTPException(status_code=400, detail="User has no tenant assignment")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
 
 
730
  # Backfill company name if missing.
731
  if not final_data.get("company") and profile.get("company"):
732
  final_data["company"] = profile["company"]
@@ -752,19 +857,28 @@ async def save_ticket(request_body: TicketSaveRequest):
752
  "is_potential_duplicate": False,
753
  "similarity": 0.0,
754
  }
 
 
755
 
756
- if duplicate_text:
757
- duplicate_result = detect_semantic_duplicate(
758
- duplicate_text,
759
- company_id=final_data.get("company_id"),
760
- threshold=duplicate_threshold,
761
- )
762
- final_data["description_vector"] = duplicate_service.generate_embedding(duplicate_text)
763
- else:
764
- final_data["description_vector"] = None
765
 
766
- final_data["is_potential_duplicate"] = duplicate_result.get("is_potential_duplicate", False)
767
- final_data["parent_ticket_id"] = duplicate_result.get("parent_ticket_id")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
768
 
769
  # --- Sanitize payload to only include valid Supabase DB columns ---
770
  # Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
@@ -772,13 +886,14 @@ async def save_ticket(request_body: TicketSaveRequest):
772
  VALID_TICKET_COLUMNS = {
773
  "user_id", "subject", "description", "category", "subcategory",
774
  "priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
775
- "confidence", "image_url", "company", "company_id", "sla_breach_at", "metadata",
 
776
  }
777
  # Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
778
  existing_metadata = final_data.get("metadata") or {}
779
  extra_keys = (
780
  "entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
781
- "is_potential_duplicate", "parent_ticket_id", "sla_response_due_at", "sla_status", "escalation_level"
782
  )
783
  for extra_key in extra_keys:
784
  if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
@@ -795,19 +910,29 @@ async def save_ticket(request_body: TicketSaveRequest):
795
 
796
  ticket_id = res.data[0]["id"]
797
 
798
- duplicate_indexed = True
799
- duplicate_index_warning = None
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  if duplicate_text:
801
  try:
 
802
  duplicate_service.add_ticket(str(ticket_id), duplicate_text)
 
 
803
  except Exception as index_error:
804
- duplicate_indexed = False
805
- duplicate_index_warning = "Duplicate index update failed."
806
- print(f"[WARNING] {duplicate_index_warning} ticket_id={ticket_id} error={index_error}")
807
- else:
808
- duplicate_indexed = False
809
- duplicate_index_warning = "Duplicate index update skipped: no description or subject text was provided."
810
- print(f"[WARNING] {duplicate_index_warning}")
811
 
812
  # Add initial system diagnostic message
813
  msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
@@ -825,12 +950,14 @@ async def save_ticket(request_body: TicketSaveRequest):
825
  response = {
826
  "status": "success",
827
  "ticket_id": ticket_id,
828
- "duplicate_indexed": duplicate_indexed,
829
- "is_potential_duplicate": final_data["is_potential_duplicate"],
830
- "parent_ticket_id": final_data["parent_ticket_id"],
831
  }
832
- if duplicate_index_warning:
833
- response["duplicate_index_warning"] = duplicate_index_warning
 
 
 
 
834
  return response
835
 
836
  except Exception as e:
@@ -838,17 +965,70 @@ async def save_ticket(request_body: TicketSaveRequest):
838
  raise HTTPException(status_code=500, detail=str(e))
839
 
840
  @app.get("/tickets/{ticket_id}")
841
- async def get_ticket_by_id(ticket_id: str):
 
 
 
842
  """Fetch single persistent ticket."""
843
  if not supabase:
844
  raise HTTPException(status_code=500, detail="Database connection not initialized")
845
-
 
 
 
 
 
 
 
846
  res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
847
  if not res.data:
848
  raise HTTPException(status_code=404, detail="Ticket not found")
849
  return res.data
850
 
851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  @app.post("/tickets", response_model=TicketRecord)
853
  async def create_ticket(ticket: TicketRecord):
854
  """Save a new ticket into the system."""
@@ -887,11 +1067,6 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
887
  Main endpoint for analyzing a new ticket using the cascade of local AI models.
888
  """
889
  text = request_body.text
890
-
891
- settings = get_system_settings(request_body.company_id)
892
- confidence_threshold = settings["ai_confidence_threshold"]
893
- duplicate_sensitivity = settings["duplicate_sensitivity"]
894
- enable_auto_resolve = settings["enable_auto_resolve"]
895
 
896
  # Grab client metadata
897
  client_ip = request.client.host if request.client else "unknown"
@@ -924,6 +1099,8 @@ async def analyze_only(request_body: TicketRequest):
924
  and duplicate check before committing to a ticket creation.
925
  """
926
  text = request_body.text
 
 
927
  print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
928
  settings = get_system_settings(request_body.company)
929
  confidence_threshold = settings["ai_confidence_threshold"]
@@ -968,6 +1145,10 @@ async def analyze_only(request_body: TicketRequest):
968
  is_potential_duplicate=False,
969
  parent_ticket_id=None,
970
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
 
 
 
 
971
  )
972
 
973
  # --- Context & Environment ---
@@ -1000,36 +1181,7 @@ async def analyze_only(request_body: TicketRequest):
1000
  summary = text[:100] + ("…" if len(text) > 100 else "")
1001
 
1002
  # --- Classification ---
1003
- try:
1004
- classification_v3_res = classifier_v3.predict(text)
1005
- if "error" in classification_v3_res:
1006
- # Fallback to V1
1007
- classification = classifier_service.predict(text)
1008
- else:
1009
- # Parse V3 output
1010
- cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
1011
- sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
1012
- pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
1013
- conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
1014
-
1015
- from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
1016
- assigned_team = TEAM_MAP.get(cat, "General Support")
1017
- auto_resolve = sub in AUTO_RESOLVE_SUBS
1018
-
1019
- classification = {
1020
- "category": cat,
1021
- "subcategory": sub,
1022
- "priority": pri,
1023
- "auto_resolve": auto_resolve,
1024
- "assigned_team": assigned_team,
1025
- "confidence": float(conf)
1026
- }
1027
- except Exception as e:
1028
- traceback.print_exc()
1029
- classification = {
1030
- "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
1031
- "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
1032
- }
1033
 
1034
  timeline["ai_analyzed"] = get_now_ist()
1035
  timeline["triaged"] = get_now_ist()
@@ -1043,21 +1195,10 @@ async def analyze_only(request_body: TicketRequest):
1043
  timeline["metadata_harvested"] = get_now_ist()
1044
 
1045
  # --- Duplicate detection ---
1046
- duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
1047
  try:
1048
- dup_result = detect_semantic_duplicate(
1049
- text,
1050
- company_id=request_body.company_id,
1051
- threshold=duplicate_threshold,
1052
- )
1053
  except Exception:
1054
- dup_result = {
1055
- "is_duplicate": False,
1056
- "duplicate_ticket_id": None,
1057
- "parent_ticket_id": None,
1058
- "is_potential_duplicate": False,
1059
- "similarity": 0.0,
1060
- }
1061
 
1062
  # --- RAG Knowledge Base Check ---
1063
  rag_match = None
@@ -1073,7 +1214,7 @@ async def analyze_only(request_body: TicketRequest):
1073
 
1074
  # --- Reasoning ---
1075
  decision_factors = []
1076
- if classification["confidence"] > confidence_threshold:
1077
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1078
  if entities:
1079
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
@@ -1083,14 +1224,6 @@ async def analyze_only(request_body: TicketRequest):
1083
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1084
 
1085
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1086
- if (
1087
- enable_auto_resolve
1088
- and classification["confidence"] >= confidence_threshold
1089
- and classification["auto_resolve"]
1090
- ):
1091
- classification["auto_resolve"] = True
1092
- else:
1093
- classification["auto_resolve"] = False
1094
  if classification["auto_resolve"]:
1095
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1096
 
@@ -1100,8 +1233,10 @@ async def analyze_only(request_body: TicketRequest):
1100
  if gemini_service and gemini_service._initialized:
1101
  summary = gemini_service.get_summary(text)
1102
 
1103
- # Convert priority to the SLA resolution target timestamp for preview.
1104
- sla_breach_dt = calculate_sla_breach_at(classification["priority"])
 
 
1105
 
1106
  return TicketResponse(
1107
  ticket_id=str(uuid.uuid4()), # Temporary ID
@@ -1114,17 +1249,21 @@ async def analyze_only(request_body: TicketRequest):
1114
  entities=[EntityInfo(**e) for e in entities],
1115
  duplicate_ticket=DuplicateInfo(**dup_result),
1116
  confidence=classification["confidence"],
1117
- needs_review=classification["confidence"] < confidence_threshold,
1118
  reasoning=reasoning,
1119
  decision_factors=decision_factors,
1120
  image_description=gemini_analysis["image_description"],
1121
  ocr_text=gemini_analysis["ocr_text"],
1122
- highlights=entities, # Use entities as highlights for now
1123
  timeline=timeline,
1124
  env_metadata=env_metadata,
1125
  is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1126
  parent_ticket_id=dup_result.get("parent_ticket_id"),
1127
- sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
 
 
 
 
1128
  )
1129
 
1130
  @app.post("/ai/analyze_stream")
@@ -1143,11 +1282,7 @@ async def analyze_stream(request_body: TicketRequest):
1143
  "model_version": "3.0.0-PRO",
1144
  "api_endpoint": "/ai/analyze_stream"
1145
  }
1146
- timeline = {"received": get_now_ist()}
1147
- settings = get_system_settings(request_body.company_id)
1148
- confidence_threshold = settings["ai_confidence_threshold"]
1149
- duplicate_sensitivity = settings["duplicate_sensitivity"]
1150
- enable_auto_resolve = settings["enable_auto_resolve"]
1151
 
1152
  # 1. Reading
1153
  yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
@@ -1175,33 +1310,7 @@ async def analyze_stream(request_body: TicketRequest):
1175
  # 3. Classification
1176
  yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
1177
  await asyncio.sleep(0.2)
1178
- try:
1179
- classification_v3_res = classifier_v3.predict(text)
1180
- if "error" in classification_v3_res:
1181
- classification = classifier_service.predict(text)
1182
- else:
1183
- cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
1184
- sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
1185
- pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
1186
- conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
1187
-
1188
- from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
1189
- assigned_team = TEAM_MAP.get(cat, "General Support")
1190
- auto_resolve = sub in AUTO_RESOLVE_SUBS
1191
-
1192
- classification = {
1193
- "category": cat,
1194
- "subcategory": sub,
1195
- "priority": pri,
1196
- "auto_resolve": auto_resolve,
1197
- "assigned_team": assigned_team,
1198
- "confidence": float(conf)
1199
- }
1200
- except Exception as e:
1201
- classification = {
1202
- "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
1203
- "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
1204
- }
1205
  timeline["ai_analyzed"] = get_now_ist()
1206
  timeline["triaged"] = get_now_ist()
1207
 
@@ -1209,20 +1318,9 @@ async def analyze_stream(request_body: TicketRequest):
1209
  yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
1210
  await asyncio.sleep(0.2)
1211
  try:
1212
- duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
1213
- dup_result = detect_semantic_duplicate(
1214
- text,
1215
- company_id=request_body.company_id,
1216
- threshold=duplicate_threshold,
1217
- )
1218
  except Exception:
1219
- dup_result = {
1220
- "is_duplicate": False,
1221
- "duplicate_ticket_id": None,
1222
- "parent_ticket_id": None,
1223
- "is_potential_duplicate": False,
1224
- "similarity": 0.0,
1225
- }
1226
 
1227
  # 5. RAG / Solutions
1228
  yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
@@ -1238,7 +1336,7 @@ async def analyze_stream(request_body: TicketRequest):
1238
  pass
1239
 
1240
  decision_factors = []
1241
- if classification["confidence"] > confidence_threshold:
1242
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1243
  if entities:
1244
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
@@ -1247,8 +1345,6 @@ async def analyze_stream(request_body: TicketRequest):
1247
  if rag_match:
1248
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1249
 
1250
- if not enable_auto_resolve:
1251
- classification["auto_resolve"] = False
1252
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1253
  if classification["auto_resolve"]:
1254
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
@@ -1258,7 +1354,9 @@ async def analyze_stream(request_body: TicketRequest):
1258
  if gemini_service and gemini_service._initialized:
1259
  summary = gemini_service.get_summary(text)
1260
 
1261
- sla_breach_dt = calculate_sla_breach_at(classification["priority"])
 
 
1262
 
1263
  ticket_response_dict = {
1264
  "ticket_id": str(uuid.uuid4()),
@@ -1271,17 +1369,15 @@ async def analyze_stream(request_body: TicketRequest):
1271
  "entities": [e for e in entities],
1272
  "duplicate_ticket": dup_result,
1273
  "confidence": classification["confidence"],
1274
- "needs_review": classification["confidence"] < confidence_threshold,
1275
  "reasoning": reasoning,
1276
  "decision_factors": decision_factors,
1277
  "image_description": gemini_analysis["image_description"],
1278
  "ocr_text": gemini_analysis["ocr_text"],
1279
- "highlights": entities,
1280
  "timeline": timeline,
1281
  "env_metadata": env_metadata,
1282
- "is_potential_duplicate": dup_result.get("is_potential_duplicate", False),
1283
- "parent_ticket_id": dup_result.get("parent_ticket_id"),
1284
- "sla_breach_at": sla_breach_dt.isoformat().replace("+00:00", "Z")
1285
  }
1286
 
1287
  # 6. Final Result
@@ -1313,3 +1409,258 @@ async def analyze_ticket_v2(request: TicketRequest):
1313
  }
1314
  except Exception as e:
1315
  raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import sys
9
  import uuid
10
  import json
11
+ import re
12
  import datetime
13
  import traceback
14
  import warnings
 
20
  warnings.filterwarnings("ignore", message="'pin_memory'")
21
 
22
  # HF Rebuild Trigger: 2026-03-08-2030
23
+ from fastapi import FastAPI, Depends, HTTPException, Request
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
 
36
  env_path = Path(__file__).parent / '.env'
37
  load_dotenv(dotenv_path=env_path)
38
 
39
+ # CI smoke tests allow degraded startup so the app can import without heavy ML assets.
40
+ ALLOW_DEGRADED_STARTUP = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
41
+
 
 
 
42
 
43
+ def _startup_fatal(message: str) -> None:
44
+ print(f"[Startup-FATAL] {message}")
45
 
46
  # Initialize Supabase Client (Service Role for backend bypass)
47
  try:
 
52
  print("[ERROR] SUPABASE_URL or SUPABASE_SERVICE_KEY not set in backend/.env")
53
  supabase = None
54
  else:
55
+ from backend.auth.crypto import wrap_client
56
+ supabase = wrap_client(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.audit_service import AuditLogService, AuditLogAccessError
69
+ from backend.services.onnx_service import onnx_classifier
70
  from backend.services.ner_service import NERService
71
  from backend.services.duplicate_service import DuplicateService
72
  from backend.services.rag_service import RagService
73
+ from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
74
+ from backend.services.redis_cache import redis_cache
75
+ from backend.auth_cookie import router as auth_cookie_router, get_current_user # noqa: F401
 
 
 
 
76
 
77
 
78
  # ---------------------------------------------------------------------------
 
121
  duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
122
  duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
123
  return duplicate_result
124
+
125
+
126
+ def classify_ticket_text(text: str) -> dict:
127
+ """Run the local classifier cascade with ONNX as the offline fallback path."""
128
+ cached = redis_cache.get_classification(text)
129
+ if cached:
130
+ return cached
131
+
132
+ result = _classify_ticket_text_uncached(text)
133
+ redis_cache.set_classification(text, result)
134
+ return result
135
+
136
+
137
+ def _classify_ticket_text_uncached(text: str) -> dict:
138
+ try:
139
+ classification_v3_res = classifier_v3.predict(text)
140
+ if "error" not in classification_v3_res:
141
+ cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
142
+ sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
143
+ pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
144
+ conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
145
+
146
+ from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
147
+ return {
148
+ "category": cat,
149
+ "subcategory": sub,
150
+ "priority": pri,
151
+ "auto_resolve": sub in AUTO_RESOLVE_SUBS,
152
+ "assigned_team": TEAM_MAP.get(cat, "General Support"),
153
+ "confidence": float(conf),
154
+ }
155
+ except Exception:
156
+ traceback.print_exc()
157
+
158
+ try:
159
+ onnx_result = onnx_classifier.predict(text)
160
+ if onnx_result:
161
+ return onnx_result
162
+ except Exception as error:
163
+ print(f"[ONNX] Fallback classification skipped: {error}")
164
+
165
+ try:
166
+ return classifier_service.predict(text)
167
+ except Exception:
168
+ traceback.print_exc()
169
+ return {
170
+ "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
171
+ "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
172
+ }
173
+
174
  class TicketRequest(BaseModel):
175
  text: str
176
  image_base64: str = ""
177
  image_text: str = "" # Keep for backward compatibility
178
  user_id: str | None = None
179
  company: str | None = None
 
180
  image_url: str | None = None
181
  confidence_threshold: float = 0.20
182
  duplicate_sensitivity: float = 0.85
 
196
  image_url: str | None = None
197
  company: str | None = None
198
  company_id: str | None = None
 
 
 
 
199
  sla_breach_at: str
200
  sla_status: str | None = None
201
  escalation_level: int = 0
 
211
  class DuplicateInfo(BaseModel):
212
  is_duplicate: bool
213
  duplicate_ticket_id: str | None = None
 
 
214
  similarity: float = 0.0
215
 
216
 
 
232
  entities: list[EntityInfo]
233
  duplicate_ticket: DuplicateInfo
234
  confidence: float
 
 
235
  needs_review: bool = False
236
  reasoning: str = ""
237
  decision_factors: list[str] = []
 
241
  timeline: dict = {} # Map of step_name: timestamp
242
  env_metadata: dict = {} # IP, Hostname, Browser/OS
243
  sla_breach_at: str | None = None
244
+ original_text: str | None = None
245
+ source_language: str = "en"
246
+ source_language_name: str = "English"
247
+ was_translated: bool = False
248
  version: str = "2.1.0-Neural-Diagnostic"
249
 
250
 
 
272
  timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
273
 
274
 
275
+ class AuditLogProfile(BaseModel):
276
+ full_name: str | None = None
277
+ email: str | None = None
278
+ profile_picture: str | None = None
279
+
280
+
281
+ class AuditLogRecord(BaseModel):
282
+ id: str
283
+ ticket_id: str
284
+ company_id: str
285
+ performed_by: str | None = None
286
+ action: str
287
+ old_value: dict | list | str | None = None
288
+ new_value: dict | list | str | None = None
289
+ created_at: str
290
+ performed_by_profile: AuditLogProfile | None = None
291
+
292
+
293
  # --- In-Memory Database (to be replaced with SQL later) ---
294
  TICKETS_DB: list[TicketRecord] = []
295
 
 
308
  # ---------------------------------------------------------------------------
309
  # Service singletons
310
  # ---------------------------------------------------------------------------
311
+ from backend.services.semantic_duplicate_service import SemanticDuplicateService
312
+
313
  classifier_service = ClassifierService()
314
  ner_service = NERService()
315
  duplicate_service = DuplicateService()
316
  rag_service = RagService()
317
+ sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
318
+ semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
319
 
320
  try:
321
  from backend.services.gemini_service import GeminiService
 
329
  except ImportError:
330
  ocr_service = None
331
 
332
+ LANGUAGE_NAMES = {
333
+ "en": "English",
334
+ "es": "Spanish",
335
+ "de": "German",
336
+ "hi": "Hindi",
337
+ "fr": "French",
338
+ "it": "Italian",
339
+ "pt": "Portuguese",
340
+ "ja": "Japanese",
341
+ "ko": "Korean",
342
+ "zh": "Chinese",
343
+ "ar": "Arabic",
344
+ "ru": "Russian",
345
+ }
346
+
347
+ def _heuristic_language_detection(text: str) -> dict:
348
+ sample = (text or "").strip()
349
+ if not sample:
350
+ return {"code": "en", "name": "English"}
351
+ ascii_chars = sum(1 for c in sample if ord(c) < 128)
352
+ ratio = ascii_chars / max(len(sample), 1)
353
+ if ratio > 0.97:
354
+ return {"code": "en", "name": "English"}
355
+ return {"code": "unknown", "name": "Unknown"}
356
+
357
+ def detect_and_translate_ticket_text(text: str) -> dict:
358
+ original_text = (text or "").strip()
359
+ if not original_text:
360
+ return {
361
+ "text_for_analysis": text or "",
362
+ "source_language": "en",
363
+ "source_language_name": "English",
364
+ "was_translated": False,
365
+ "original_text": "",
366
+ "metadata":{},
367
+ }
368
+
369
+ detected = _heuristic_language_detection(original_text)
370
+ if gemini_service and getattr(gemini_service, "_initialized", False):
371
+ detected = gemini_service.detect_language(original_text)
372
+
373
+ source_code = str(detected.get("code", "en")).lower()
374
+ source_name = detected.get("name") or LANGUAGE_NAMES.get(source_code, source_code.upper())
375
+ if source_code in ("en", "eng"):
376
+ return {
377
+ "text_for_analysis": original_text,
378
+ "source_language": "en",
379
+ "source_language_name": "English",
380
+ "was_translated": False,
381
+ "original_text": original_text,
382
+ "metadata":{},
383
+ }
384
+
385
+ translated_text = original_text
386
+ if gemini_service and getattr(gemini_service, "_initialized", False):
387
+ translated_text = gemini_service.translate_to_english(original_text, source_name)
388
+
389
+ if not translated_text or translated_text.strip() == original_text:
390
+ return {
391
+ "text_for_analysis": original_text,
392
+ "source_language": source_code,
393
+ "source_language_name": source_name,
394
+ "was_translated": False,
395
+ "original_text": original_text,
396
+ "metadata":{},
397
+ }
398
+
399
+ return {
400
+ "text_for_analysis": translated_text.strip(),
401
+ "source_language": source_code,
402
+ "source_language_name": source_name,
403
+ "was_translated": True,
404
+ "original_text": original_text,
405
+ "metadata":{},
406
+ }
407
+
408
 
409
  # ---------------------------------------------------------------------------
410
  # Lifespan (startup / shutdown)
 
414
  """Load all models at startup."""
415
  print("[Startup] Loading AI models ...")
416
  try:
417
+ redis_cache.connect()
418
  except Exception as e:
419
+ print(f"[WARNING] Redis cache not available: {e}")
420
+ try:
421
+ classifier_service.load()
422
+ except FileNotFoundError as e:
423
  print(f"[WARNING] Classifier not loaded: {e}")
424
  try:
425
  ner_service.load()
426
+ except FileNotFoundError as e:
427
  print(f"[WARNING] NER not loaded: {e}")
428
  try:
429
  duplicate_service.load()
 
433
  rag_service.load()
434
  except Exception as e:
435
  print(f"[WARNING] RAG service not loaded: {e}")
436
+ try:
437
+ onnx_classifier.load()
438
+ except Exception as e:
439
+ print(f"[WARNING] ONNX classifier fallback not loaded: {e}")
440
 
441
  if gemini_service:
442
  print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
443
  else:
444
  print("[Startup] Gemini Service: NOT LOADED (Import failed)")
445
 
446
+ # Wire services with supabase client
447
+ sla_engine.supabase = supabase
448
+ semantic_dupe_service.supabase = supabase
 
 
 
 
 
449
 
450
+ # Pre-load embedding model so first ticket save is fast
451
+ try:
452
+ semantic_dupe_service.load()
453
+ print(f"[Startup] Semantic Duplicate Detection: {'Loaded' if semantic_dupe_service._loaded else 'Failed (model missing)'}")
454
+ except Exception as e:
455
+ print(f"[Startup] Semantic Duplicate Detection load error: {e}")
456
+ print(f"[Startup] SLA Engine: {'Initialized' if supabase else 'Offline (no DB)'}")
457
 
458
+ # Start background SLA checker as an async task (every 5 minutes)
459
+ if supabase:
460
+ from backend.sla_checker import sla_checker_loop_async
461
+ asyncio.create_task(sla_checker_loop_async(supabase, interval_seconds=300))
462
+ print("[Startup] SLA background checker started (interval=300s)")
463
 
464
+ print("[Startup] Classifier V2 Shadow: Ready.")
465
+ print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
466
+ print("[Startup] Ready.")
467
+ yield
468
+ print("[Shutdown] Cleaning up ...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
 
470
 
471
  # ---------------------------------------------------------------------------
 
496
  allow_headers=["*"],
497
  )
498
 
499
+ app.include_router(auth_cookie_router)
500
+
501
 
502
  # ---------------------------------------------------------------------------
503
  # Root & Health check
 
584
  """
585
 
586
 
 
 
 
 
 
 
 
 
 
 
 
587
  @app.get("/health", response_model=HealthResponse)
588
  async def health_check():
589
  return HealthResponse(
 
596
  @app.get("/ready", response_model=ReadinessResponse)
597
  async def readiness_check():
598
  require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
 
 
599
  checks = {
600
  "api": True,
601
  "classifier_loaded": classifier_service._loaded,
602
  "ner_loaded": ner_service._loaded,
603
+ "duplicate_index_loaded": duplicate_service._loaded,
604
+ "rag_loaded": rag_service._loaded,
605
  }
606
  if require_supabase:
607
  checks["supabase_configured"] = supabase is not None
608
 
609
+ if all(checks.values()):
610
+ return ReadinessResponse(status="ready", checks=checks)
 
 
 
 
 
 
 
 
 
611
 
612
  return JSONResponse(
613
  status_code=503,
 
748
  res = query.execute()
749
  return res.data
750
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  @app.post("/tickets/save")
752
  async def save_ticket(request_body: TicketSaveRequest):
753
  """
 
758
  raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
759
 
760
  logger = logging.getLogger(__name__)
761
+ final_data = request_body.model_dump()
762
+ original_subject = final_data.get("subject", "") or ""
763
+ original_description = final_data.get("description", "") or ""
764
+
765
+ # Detect language and translate subject/description into English before downstream routing/indexing.
766
+ translation_probe_text = (original_description.strip() or original_subject.strip())
767
+ translation_ctx = detect_and_translate_ticket_text(translation_probe_text)
768
+ metadata = final_data.get("metadata") or {}
769
+ if translation_ctx["was_translated"]:
770
+ translated_subject = gemini_service.translate_to_english(original_subject, translation_ctx["source_language_name"]) if original_subject else original_subject
771
+ translated_description = gemini_service.translate_to_english(original_description, translation_ctx["source_language_name"]) if original_description else original_description
772
+ final_data["subject"] = translated_subject or original_subject
773
+ final_data["description"] = translated_description or original_description
774
+ metadata["original_text"] = {
775
+ "subject": original_subject,
776
+ "description": original_description,
777
+ }
778
+ metadata["translation"] = {
779
+ "translated": bool(translation_ctx["was_translated"]),
780
+ "source_language": translation_ctx["source_language"],
781
+ "source_language_name": translation_ctx["source_language_name"],
782
+ }
783
+ final_data["metadata"] = metadata
784
+
785
+ # Backfill SLA deadlines/status when the client omits or sends empty values.
786
+ priority_key = str(final_data.get("priority") or "medium").lower().strip()
787
+ now_utc = datetime.datetime.now(datetime.timezone.utc)
788
+
789
+ if not str(final_data.get("sla_breach_at") or "").strip():
790
+ final_data["sla_breach_at"] = compute_sla_breach_at(priority_key, now_utc)
791
+
792
+ if not str(final_data.get("sla_response_due_at") or "").strip():
793
+ policy = get_sla_policy(priority_key)
794
+ response_hours = max(1, int(round(float(policy["max_hours"]) * 0.25)))
795
+ response_due_at = now_utc + datetime.timedelta(hours=response_hours)
796
+ final_data["sla_response_due_at"] = response_due_at.isoformat()
797
+
798
+ if not str(final_data.get("sla_status") or "").strip():
799
+ final_data["sla_status"] = "ACTIVE"
800
+ # Resolve tenant linkage from user profile with authorization validation.
801
+ profile = {}
802
+ if request_body.user_id:
803
+ try:
804
+ profile_res = (
805
+ supabase.table("profiles")
806
+ .select("company_id, company")
807
+ .eq("id", request_body.user_id)
808
+ .single()
809
+ .execute()
810
+ )
811
+ profile = profile_res.data or {}
812
+ if not profile:
813
+ raise HTTPException(status_code=404, detail="User profile not found")
814
+ except HTTPException:
815
+ raise
816
+ except Exception as profile_error:
817
+ logger.error(f"Tenant resolution error for user {request_body.user_id}: {profile_error}")
818
+ raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
819
+
820
+ # Validate tenant consistency and authorization.
821
+ profile_company_id = profile.get("company_id")
822
+ if final_data.get("company_id"):
823
+ # User provided company_id: verify it matches their profile.
824
+ if profile_company_id and final_data["company_id"] != profile_company_id:
825
+ logger.warning(f"Tenant mismatch: user {request_body.user_id} attempted {final_data['company_id']}, assigned to {profile_company_id}")
826
+ raise HTTPException(status_code=403, detail="User not authorized for this tenant")
827
+ elif profile_company_id:
828
+ # Backfill company_id from profile.
829
+ final_data["company_id"] = profile_company_id
830
+ elif request_body.user_id:
831
+ # User has no tenant assignment.
832
+ raise HTTPException(status_code=400, detail="User has no tenant assignment")
833
 
834
+ try:
835
  # Backfill company name if missing.
836
  if not final_data.get("company") and profile.get("company"):
837
  final_data["company"] = profile["company"]
 
857
  "is_potential_duplicate": False,
858
  "similarity": 0.0,
859
  }
860
+ user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
861
+ logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
862
 
 
 
 
 
 
 
 
 
 
863
 
864
+ # Semantic duplicate check BEFORE inserting the ticket
865
+ # This allows us to warn the user before confirming
866
+ duplicate_check_result = None
867
+ try:
868
+ dupe_text = (request_body.description or request_body.subject or "").strip()
869
+ if dupe_text:
870
+ duplicate_check_result = await semantic_dupe_service.check_duplicate(
871
+ text=dupe_text,
872
+ company_id=final_data.get("company_id"),
873
+ )
874
+ if duplicate_check_result["is_duplicate"]:
875
+ logger.info(
876
+ f"[DUPLICATE] Ticket flagged as potential duplicate of "
877
+ f"{duplicate_check_result['duplicate_ticket_id']} "
878
+ f"(similarity: {duplicate_check_result['similarity']})"
879
+ )
880
+ except Exception as e:
881
+ logger.warning(f"[DUPLICATE] Semantic check error (non-fatal): {e}")
882
 
883
  # --- Sanitize payload to only include valid Supabase DB columns ---
884
  # Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
 
886
  VALID_TICKET_COLUMNS = {
887
  "user_id", "subject", "description", "category", "subcategory",
888
  "priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
889
+ "confidence", "image_url", "company", "company_id",
890
+ "sla_breach_at", "sla_response_due_at", "sla_status", "escalation_level", "metadata",
891
  }
892
  # Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
893
  existing_metadata = final_data.get("metadata") or {}
894
  extra_keys = (
895
  "entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
896
+ "is_potential_duplicate", "parent_ticket_id"
897
  )
898
  for extra_key in extra_keys:
899
  if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
 
910
 
911
  ticket_id = res.data[0]["id"]
912
 
913
+ # If duplicate detected, link parent ticket
914
+ if duplicate_check_result and duplicate_check_result["is_duplicate"]:
915
+ try:
916
+ supabase.table("tickets").update({
917
+ "is_potential_duplicate": True,
918
+ "parent_ticket_id": duplicate_check_result["duplicate_ticket_id"],
919
+ }).eq("id", ticket_id).execute()
920
+ except Exception as e:
921
+ logger.warning(f"[DUPLICATE] Failed to link parent ticket: {e}")
922
+
923
+ # Index the new ticket's embedding for future duplicate checks
924
+ embedding_indexed = False
925
+ description_text = (request_body.description or "").strip()
926
+ subject_text = (request_body.subject or "").strip()
927
+ duplicate_text = description_text or subject_text
928
  if duplicate_text:
929
  try:
930
+ # Both: old in-memory index (for backward compat) and new pgvector index
931
  duplicate_service.add_ticket(str(ticket_id), duplicate_text)
932
+ asyncio.create_task(semantic_dupe_service.index_ticket(ticket_id, duplicate_text))
933
+ embedding_indexed = True
934
  except Exception as index_error:
935
+ logger.warning(f"[INDEX] Failed to index ticket {ticket_id}: {index_error}")
 
 
 
 
 
 
936
 
937
  # Add initial system diagnostic message
938
  msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
 
950
  response = {
951
  "status": "success",
952
  "ticket_id": ticket_id,
953
+ "duplicate_indexed": embedding_indexed,
 
 
954
  }
955
+ if duplicate_check_result and duplicate_check_result["is_duplicate"]:
956
+ response["duplicate_warning"] = True
957
+ response["parent_ticket_id"] = duplicate_check_result["duplicate_ticket_id"]
958
+ response["parent_subject"] = duplicate_check_result.get("parent_subject")
959
+ response["similarity"] = duplicate_check_result["similarity"]
960
+ response["candidates"] = duplicate_check_result.get("candidates", [])
961
  return response
962
 
963
  except Exception as e:
 
965
  raise HTTPException(status_code=500, detail=str(e))
966
 
967
  @app.get("/tickets/{ticket_id}")
968
+ async def get_ticket_by_id(
969
+ request: Request,
970
+ ticket_id: str,
971
+ ):
972
  """Fetch single persistent ticket."""
973
  if not supabase:
974
  raise HTTPException(status_code=500, detail="Database connection not initialized")
975
+
976
+ # Guard route overlap where '/tickets/search' may be matched here first.
977
+ if ticket_id == "search":
978
+ return await search_tickets(
979
+ q=request.query_params.get("q", ""),
980
+ company_id=request.query_params.get("company_id"),
981
+ )
982
+
983
  res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
984
  if not res.data:
985
  raise HTTPException(status_code=404, detail="Ticket not found")
986
  return res.data
987
 
988
 
989
+ @app.get("/tickets/{ticket_id}/audit_logs", response_model=list[AuditLogRecord])
990
+ async def get_ticket_audit_logs(ticket_id: str, company_id: str):
991
+ """Return a company-scoped chronological audit trail for a ticket."""
992
+ if not supabase:
993
+ raise HTTPException(status_code=500, detail="Database connection not initialized")
994
+
995
+ try:
996
+ service = AuditLogService(supabase)
997
+ return service.get_ticket_audit_logs(ticket_id, company_id)
998
+ except AuditLogAccessError as err:
999
+ raise HTTPException(status_code=err.status_code, detail=err.detail)
1000
+
1001
+
1002
+ @app.get("/tickets/search")
1003
+ async def search_tickets(q: str, company_id: str | None = None):
1004
+ """Search tickets by query text, optionally scoped by company_id."""
1005
+ if not supabase:
1006
+ raise HTTPException(status_code=500, detail="Database connection not initialized")
1007
+ query_text = (q or "").strip()
1008
+ if not query_text:
1009
+ raise HTTPException(status_code=400, detail="Query text is required")
1010
+
1011
+ try:
1012
+ rpc_res = supabase.rpc(
1013
+ "search_tickets",
1014
+ {"query_text": query_text, "company_id": company_id},
1015
+ ).execute()
1016
+ return rpc_res.data or []
1017
+ except Exception:
1018
+ # Fallback for environments without RPC function support.
1019
+ fallback = supabase.table("tickets").select("*").order("created_at", desc=True).execute()
1020
+ rows = fallback.data or []
1021
+ lowered = query_text.lower()
1022
+ filtered = [
1023
+ row for row in rows
1024
+ if lowered in str(row.get("subject", "")).lower()
1025
+ or lowered in str(row.get("description", "")).lower()
1026
+ ]
1027
+ if company_id:
1028
+ filtered = [row for row in filtered if row.get("company_id") == company_id]
1029
+ return filtered
1030
+
1031
+
1032
  @app.post("/tickets", response_model=TicketRecord)
1033
  async def create_ticket(ticket: TicketRecord):
1034
  """Save a new ticket into the system."""
 
1067
  Main endpoint for analyzing a new ticket using the cascade of local AI models.
1068
  """
1069
  text = request_body.text
 
 
 
 
 
1070
 
1071
  # Grab client metadata
1072
  client_ip = request.client.host if request.client else "unknown"
 
1099
  and duplicate check before committing to a ticket creation.
1100
  """
1101
  text = request_body.text
1102
+ translation_ctx = detect_and_translate_ticket_text(text)
1103
+ text = translation_ctx["text_for_analysis"]
1104
  print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
1105
  settings = get_system_settings(request_body.company)
1106
  confidence_threshold = settings["ai_confidence_threshold"]
 
1145
  is_potential_duplicate=False,
1146
  parent_ticket_id=None,
1147
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
1148
+ original_text=request_body.text,
1149
+ source_language=translation_ctx["source_language"],
1150
+ source_language_name=translation_ctx["source_language_name"],
1151
+ was_translated=translation_ctx["was_translated"],
1152
  )
1153
 
1154
  # --- Context & Environment ---
 
1181
  summary = text[:100] + ("…" if len(text) > 100 else "")
1182
 
1183
  # --- Classification ---
1184
+ classification = classify_ticket_text(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1185
 
1186
  timeline["ai_analyzed"] = get_now_ist()
1187
  timeline["triaged"] = get_now_ist()
 
1195
  timeline["metadata_harvested"] = get_now_ist()
1196
 
1197
  # --- Duplicate detection ---
 
1198
  try:
1199
+ dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
 
 
 
 
1200
  except Exception:
1201
+ dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
 
 
 
 
 
 
1202
 
1203
  # --- RAG Knowledge Base Check ---
1204
  rag_match = None
 
1214
 
1215
  # --- Reasoning ---
1216
  decision_factors = []
1217
+ if classification["confidence"] > request_body.confidence_threshold:
1218
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1219
  if entities:
1220
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
 
1224
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1225
 
1226
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
 
 
 
 
 
 
 
 
1227
  if classification["auto_resolve"]:
1228
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1229
 
 
1233
  if gemini_service and gemini_service._initialized:
1234
  summary = gemini_service.get_summary(text)
1235
 
1236
+ # Convert priority to SLA breached timestamp (for preview)
1237
+ hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
1238
+ sla_hours = hours_map.get(classification["priority"], 72)
1239
+ sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
1240
 
1241
  return TicketResponse(
1242
  ticket_id=str(uuid.uuid4()), # Temporary ID
 
1249
  entities=[EntityInfo(**e) for e in entities],
1250
  duplicate_ticket=DuplicateInfo(**dup_result),
1251
  confidence=classification["confidence"],
1252
+ needs_review=classification["confidence"] < 0.20,
1253
  reasoning=reasoning,
1254
  decision_factors=decision_factors,
1255
  image_description=gemini_analysis["image_description"],
1256
  ocr_text=gemini_analysis["ocr_text"],
1257
+ highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1258
  timeline=timeline,
1259
  env_metadata=env_metadata,
1260
  is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1261
  parent_ticket_id=dup_result.get("parent_ticket_id"),
1262
+ sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z"),
1263
+ original_text=translation_ctx["original_text"],
1264
+ source_language=translation_ctx["source_language"],
1265
+ source_language_name=translation_ctx["source_language_name"],
1266
+ was_translated=translation_ctx["was_translated"],
1267
  )
1268
 
1269
  @app.post("/ai/analyze_stream")
 
1282
  "model_version": "3.0.0-PRO",
1283
  "api_endpoint": "/ai/analyze_stream"
1284
  }
1285
+ timeline = {"received": get_now_ist()}
 
 
 
 
1286
 
1287
  # 1. Reading
1288
  yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
 
1310
  # 3. Classification
1311
  yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
1312
  await asyncio.sleep(0.2)
1313
+ classification = classify_ticket_text(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1314
  timeline["ai_analyzed"] = get_now_ist()
1315
  timeline["triaged"] = get_now_ist()
1316
 
 
1318
  yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
1319
  await asyncio.sleep(0.2)
1320
  try:
1321
+ dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
 
 
 
 
 
1322
  except Exception:
1323
+ dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
 
 
 
 
 
 
1324
 
1325
  # 5. RAG / Solutions
1326
  yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
 
1336
  pass
1337
 
1338
  decision_factors = []
1339
+ if classification["confidence"] > request_body.confidence_threshold:
1340
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1341
  if entities:
1342
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
 
1345
  if rag_match:
1346
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1347
 
 
 
1348
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1349
  if classification["auto_resolve"]:
1350
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
 
1354
  if gemini_service and gemini_service._initialized:
1355
  summary = gemini_service.get_summary(text)
1356
 
1357
+ hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
1358
+ sla_hours = hours_map.get(classification["priority"], 72)
1359
+ sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
1360
 
1361
  ticket_response_dict = {
1362
  "ticket_id": str(uuid.uuid4()),
 
1369
  "entities": [e for e in entities],
1370
  "duplicate_ticket": dup_result,
1371
  "confidence": classification["confidence"],
1372
+ "needs_review": classification["confidence"] < 0.20,
1373
  "reasoning": reasoning,
1374
  "decision_factors": decision_factors,
1375
  "image_description": gemini_analysis["image_description"],
1376
  "ocr_text": gemini_analysis["ocr_text"],
1377
+ "highlights": [e.get("text", "") for e in entities],
1378
  "timeline": timeline,
1379
  "env_metadata": env_metadata,
1380
+ "sla_breach_at": sla_breach_dt.isoformat() + "Z"
 
 
1381
  }
1382
 
1383
  # 6. Final Result
 
1409
  }
1410
  except Exception as e:
1411
  raise HTTPException(status_code=500, detail=str(e))
1412
+
1413
+
1414
+ # ---------------------------------------------------------------------------
1415
+ # SLA Engine Endpoints
1416
+ # ---------------------------------------------------------------------------
1417
+
1418
+ class SLAStatsResponse(BaseModel):
1419
+ total: int = 0
1420
+ active: int = 0
1421
+ breached: int = 0
1422
+ warning: int = 0
1423
+ met: int = 0
1424
+ breach_rate: float = 0.0
1425
+ by_priority: dict = {}
1426
+
1427
+
1428
+ @app.get("/sla/stats", response_model=SLAStatsResponse)
1429
+ async def sla_stats():
1430
+ """Get aggregated SLA dashboard statistics across all tickets."""
1431
+ if not supabase:
1432
+ raise HTTPException(status_code=503, detail="Database not connected")
1433
+ stats = await sla_engine.get_dashboard_stats()
1434
+ if "error" in stats:
1435
+ raise HTTPException(status_code=500, detail=stats["error"])
1436
+ return stats
1437
+
1438
+
1439
+ class SLATicketInfo(BaseModel):
1440
+ id: str
1441
+ ticket_id: str | None = None
1442
+ subject: str | None = None
1443
+ summary: str | None = None
1444
+ priority: str = "medium"
1445
+ status: str | None = None
1446
+ assigned_team: str | None = None
1447
+ sla_status: str = "active"
1448
+ escalation_level: int = 0
1449
+ remaining_seconds: int = 0
1450
+ created_at: str | None = None
1451
+ sla_breach_at: str | None = None
1452
+ sla_warning_at: str | None = None
1453
+ last_escalated_at: str | None = None
1454
+
1455
+
1456
+ @app.get("/sla/tickets")
1457
+ async def sla_tickets(
1458
+ status: str | None = None,
1459
+ priority: str | None = None,
1460
+ limit: int = 100,
1461
+ offset: int = 0,
1462
+ ):
1463
+ """
1464
+ List tickets with SLA status. Filter by sla_status and/or priority.
1465
+ """
1466
+ if not supabase:
1467
+ raise HTTPException(status_code=503, detail="Database not connected")
1468
+
1469
+ query = (
1470
+ supabase.table("tickets")
1471
+ .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")
1472
+ .order("created_at", desc=True)
1473
+ )
1474
+
1475
+ if status and status != "all":
1476
+ query = query.eq("sla_status", status)
1477
+ if priority and priority != "all":
1478
+ query = query.eq("priority", priority.capitalize())
1479
+
1480
+ query = query.range(offset, offset + limit - 1)
1481
+ res = query.execute()
1482
+ return {"tickets": res.data or [], "total": len(res.data or [])}
1483
+
1484
+
1485
+ class EscalationLogEntry(BaseModel):
1486
+ id: str
1487
+ ticket_id: str | None = None
1488
+ ticket_subject: str = ""
1489
+ priority: str = "medium"
1490
+ sla_status: str = ""
1491
+ escalation_level: int = 0
1492
+ remaining_seconds: int = 0
1493
+ assigned_team: str = ""
1494
+ notification_channels: list = []
1495
+ triggered_at: str | None = None
1496
+ resolved_at: str | None = None
1497
+ notes: str = ""
1498
+
1499
+
1500
+ @app.get("/sla/escalations")
1501
+ async def sla_escalations(limit: int = 50, offset: int = 0):
1502
+ """Fetch escalation log history."""
1503
+ if not supabase:
1504
+ raise HTTPException(status_code=503, detail="Database not connected")
1505
+
1506
+ try:
1507
+ res = (
1508
+ supabase.table("escalation_logs")
1509
+ .select("*")
1510
+ .order("triggered_at", desc=True)
1511
+ .range(offset, offset + limit - 1)
1512
+ .execute()
1513
+ )
1514
+ return {"escalations": res.data or [], "total": len(res.data or [])}
1515
+ except Exception as e:
1516
+ # Table might not exist yet
1517
+ print(f"[SLA] Escalation logs query failed: {e}")
1518
+ return {"escalations": [], "total": 0}
1519
+
1520
+
1521
+ class SLAPolicyInfo(BaseModel):
1522
+ id: str
1523
+ priority: str
1524
+ max_hours: int
1525
+ warning_pct: float
1526
+ auto_escalate: bool
1527
+ l2_after_minutes: int
1528
+ l3_after_minutes: int
1529
+
1530
+
1531
+ @app.get("/sla/policies")
1532
+ async def sla_policies():
1533
+ """Get configured SLA policies."""
1534
+ if not supabase:
1535
+ # Return defaults from code
1536
+ policies = []
1537
+ for pri, cfg in sla_engine.SLA_POLICIES.items() if hasattr(sla_engine, 'SLA_POLICIES') else SLA_POLICIES.items():
1538
+ policies.append({
1539
+ "priority": pri,
1540
+ "max_hours": cfg["max_hours"],
1541
+ "warning_pct": cfg["warning_pct"],
1542
+ "auto_escalate": cfg["auto_escalate_on_breach"],
1543
+ "l2_after_minutes": cfg["l2_escalation_mins"],
1544
+ "l3_after_minutes": cfg["l3_escalation_mins"],
1545
+ })
1546
+ return {"policies": policies}
1547
+
1548
+ try:
1549
+ res = supabase.table("sla_policies").select("*").execute()
1550
+ return {"policies": res.data or []}
1551
+ except Exception as e:
1552
+ print(f"[SLA] Policies query failed: {e}")
1553
+ return {"policies": []}
1554
+
1555
+
1556
+ @app.post("/sla/check")
1557
+ async def trigger_sla_check():
1558
+ """Manually trigger an SLA evaluation cycle (admin)."""
1559
+ if not supabase:
1560
+ raise HTTPException(status_code=503, detail="Database not connected")
1561
+
1562
+ asyncio.create_task(sla_engine.check_all_active_tickets())
1563
+ return {"status": "triggered", "message": "SLA check cycle started in background"}
1564
+
1565
+
1566
+ # ---------------------------------------------------------------------------
1567
+ # Semantic Duplicate Detection Endpoints
1568
+ # ---------------------------------------------------------------------------
1569
+
1570
+ @app.post("/ai/check_duplicate")
1571
+ async def check_duplicate_endpoint(
1572
+ body: TicketRequest,
1573
+ company_id: str | None = None,
1574
+ ):
1575
+ """
1576
+ Check a ticket text for potential duplicates using semantic vector search.
1577
+ Returns top candidates with similarity scores.
1578
+ """
1579
+ text = (body.text or "").strip()
1580
+ if not text:
1581
+ raise HTTPException(status_code=400, detail="No text provided")
1582
+
1583
+ threshold = body.duplicate_sensitivity if hasattr(body, 'duplicate_sensitivity') else None
1584
+ result = await semantic_dupe_service.check_duplicate(
1585
+ text=text,
1586
+ company_id=company_id or body.company,
1587
+ threshold=threshold,
1588
+ )
1589
+ return result
1590
+
1591
+
1592
+ @app.post("/ai/reindex_embeddings")
1593
+ async def reindex_embeddings():
1594
+ """Re-generate vector embeddings for all tickets."""
1595
+ result = await semantic_dupe_service.reindex_all()
1596
+ return result
1597
+
1598
+
1599
+ @app.get("/system/settings")
1600
+ async def get_system_settings_endpoint():
1601
+ """Fetch all system settings."""
1602
+ if not supabase:
1603
+ raise HTTPException(status_code=503, detail="Database not connected")
1604
+ try:
1605
+ res = supabase.table("system_settings").select("*").execute()
1606
+ settings = {}
1607
+ for row in res.data or []:
1608
+ settings[row["key"]] = row["value"]
1609
+ return settings
1610
+ except Exception as e:
1611
+ logger.warning(f"[SETTINGS] Query failed: {e}")
1612
+ return {}
1613
+
1614
+
1615
+ @app.patch("/system/settings")
1616
+ async def update_system_settings(body: dict):
1617
+ """Update a specific system setting."""
1618
+ if not supabase:
1619
+ raise HTTPException(status_code=503, detail="Database not connected")
1620
+ key = body.get("key")
1621
+ value = body.get("value")
1622
+ if not key or value is None:
1623
+ raise HTTPException(status_code=400, detail="key and value required")
1624
+ try:
1625
+ supabase.table("system_settings").upsert({
1626
+ "key": key,
1627
+ "value": value,
1628
+ "updated_at": datetime.datetime.utcnow().isoformat() + "Z",
1629
+ }).execute()
1630
+ return {"status": "updated", "key": key}
1631
+ except Exception as e:
1632
+ raise HTTPException(status_code=500, detail=str(e))
1633
+
1634
+
1635
+ @app.get("/sla/tickets/{ticket_id}")
1636
+ async def sla_ticket_detail(ticket_id: str):
1637
+ """Get detailed SLA info for a specific ticket."""
1638
+ if not supabase:
1639
+ raise HTTPException(status_code=503, detail="Database not connected")
1640
+
1641
+ # Fetch ticket
1642
+ res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
1643
+ if not res.data:
1644
+ raise HTTPException(status_code=404, detail="Ticket not found")
1645
+
1646
+ ticket = res.data
1647
+ result = sla_engine.evaluate_ticket(ticket)
1648
+
1649
+ # Fetch escalation history for this ticket
1650
+ try:
1651
+ esc_res = (
1652
+ supabase.table("escalation_logs")
1653
+ .select("*")
1654
+ .eq("ticket_id", ticket_id)
1655
+ .order("triggered_at", desc=True)
1656
+ .execute()
1657
+ )
1658
+ escalations = esc_res.data or []
1659
+ except Exception:
1660
+ escalations = []
1661
+
1662
+ return {
1663
+ "ticket": ticket,
1664
+ "sla_evaluation": result,
1665
+ "escalations": escalations,
1666
+ }
backend/requirements.txt CHANGED
@@ -4,6 +4,8 @@ datasets>=2.14.0
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,4 +17,9 @@ easyocr
15
  slowapi>=0.1.9
16
  supabase==2.22.4
17
  storage3==2.22.4
18
- prometheus-client>=0.17.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
  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
+
backend/services/classifier_service.py CHANGED
@@ -6,27 +6,20 @@ Priority and other fields are derived from the category mapping.
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
@@ -72,26 +65,19 @@ class ClassifierService:
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,74 +99,65 @@ class ClassifierService:
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)
 
6
 
7
  import os
8
  import json
9
+ try:
10
+ import torch
11
+ import torch.nn.functional as F
12
+ from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification
13
+ _HAS_TORCH = True
14
+ except Exception: # pragma: no cover - optional CI/runtime dependency
15
+ torch = None
16
+ F = None
17
+ DistilBertTokenizerFast = None
18
+ DistilBertForSequenceClassification = None
19
+ _HAS_TORCH = False
 
 
 
 
 
 
 
20
 
21
  SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "classifier")
22
+ DEVICE = torch.device("cuda" if torch and torch.cuda.is_available() else "cpu") if _HAS_TORCH else None
23
  MAX_LEN = 128
24
 
25
  # Priority mapping based on sub-category severity
 
65
  if self._loaded:
66
  return
67
 
68
+ if not _HAS_TORCH:
69
+ # Degraded environment: ML runtime not available. Delay failure until predict is called.
70
+ print("[INFO] ML runtime not available; classifier will remain unloaded until dependencies are installed.")
71
+ return
72
+
73
  abs_dir = os.path.abspath(SAVE_DIR)
 
74
 
75
+ if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
76
  raise FileNotFoundError(
77
  f"Classifier model not found at {abs_dir}. "
78
  "Please ensure model files are present."
79
  )
80
 
 
 
 
 
 
 
 
 
 
 
 
81
  # Load label mappings
82
  with open(os.path.join(abs_dir, "id2label.json"), "r") as f:
83
  self.id2label = json.load(f)
 
99
  """
100
  Predict category, subcategory, priority, auto_resolve, assigned_team, and confidence.
101
  """
102
+ self.load()
103
+
104
+ encoding = self.tokenizer(
105
+ text,
106
+ truncation=True,
107
+ padding="max_length",
108
+ max_length=MAX_LEN,
109
+ return_tensors="pt",
110
+ )
111
+ input_ids = encoding["input_ids"].to(DEVICE)
112
+ attention_mask = encoding["attention_mask"].to(DEVICE)
113
+
114
+ with torch.no_grad():
115
+ outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
116
+ logits = outputs.logits
117
+ probs = F.softmax(logits, dim=1)
118
+ confidence, pred_idx = torch.max(probs, dim=1)
119
+
120
+ pred_idx = pred_idx.item()
121
+ confidence = round(confidence.item(), 4)
122
+
123
+ # Decode the combined label "Category | SubCategory"
124
+ combined_label = self.id2label.get(str(pred_idx), "Unknown | Unknown")
125
+ parts = combined_label.split(" | ", 1)
126
+ category = parts[0].strip() if len(parts) > 0 else "Unknown"
127
+ subcategory = parts[1].strip() if len(parts) > 1 else "Unknown"
128
+
129
+ # Derive priority
130
+ priority = PRIORITY_MAP.get(subcategory, "Medium")
131
+
132
+ # Derive assigned team
133
+ assigned_team = TEAM_MAP.get(category, "General Support")
134
+
135
+ # Derive auto_resolve
136
+ auto_resolve = subcategory in AUTO_RESOLVE_SUBS
137
+
138
+ # --- Regex Override Layer (Boost for Technical Keywords) ---
139
+ tech_keywords = {
140
+ "Network": ["IP address", "hostname", "connection", "network", "bandwidth", "DNS", "firewall", "VPN", "Connectivity", "Latency", "Routing", "Spikes"],
141
+ "Software": ["crash", "load", "website", "application", "error", "bug", "failing", "software", "SQL", "Cluster", "Database", "Production", "Latency"],
142
+ "Access": ["login", "password", "access", "authentication", "account", "permission", "MFA", "OAuth"]
143
+ }
144
+
145
+ lower_text = text.lower()
146
+ for cat, keywords in tech_keywords.items():
147
+ if any(k.lower() in lower_text for k in keywords):
148
+ # If current prediction is generic, or we have a high-value technical keyword
149
+ if category == "General" or confidence < 0.9:
150
+ category = cat
151
+ assigned_team = TEAM_MAP.get(cat, "General Support")
152
+ # Boost confidence significantly for verified technical signals
153
+ confidence = max(confidence, 0.92)
154
+ break
155
+
156
+ return {
157
+ "category": category,
158
+ "subcategory": subcategory,
159
+ "priority": priority,
160
+ "auto_resolve": auto_resolve,
161
+ "assigned_team": assigned_team,
162
+ "confidence": confidence,
163
+ }
 
 
 
 
 
 
 
 
 
backend/services/classifier_v2.py CHANGED
@@ -1,89 +1,109 @@
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()
 
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()
 
backend/services/classifier_v3.py CHANGED
@@ -1,75 +1,95 @@
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()
 
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()
backend/services/duplicate_service.py CHANGED
@@ -7,7 +7,13 @@ import uuid
7
  import os
8
  from typing import Any
9
 
10
- from sentence_transformers import SentenceTransformer, util
 
 
 
 
 
 
11
 
12
  SIMILARITY_THRESHOLD = 0.70
13
 
@@ -32,6 +38,17 @@ class DuplicateService:
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,12 +118,20 @@ class DuplicateService:
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,
 
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
  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
 
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,
backend/services/gemini_service.py CHANGED
@@ -2,6 +2,7 @@ import os
2
  import base64
3
  import io
4
  import re
 
5
  from PIL import Image
6
  from google import genai
7
  from dotenv import load_dotenv
@@ -222,3 +223,65 @@ class GeminiService:
222
  except Exception as e:
223
  print(f"[GeminiService] Bug Analysis Error: {e}")
224
  return f"Diagnostic analysis failed: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import base64
3
  import io
4
  import re
5
+ import json
6
  from PIL import Image
7
  from google import genai
8
  from dotenv import load_dotenv
 
223
  except Exception as e:
224
  print(f"[GeminiService] Bug Analysis Error: {e}")
225
  return f"Diagnostic analysis failed: {str(e)}"
226
+
227
+ def detect_language(self, text: str) -> dict:
228
+ """
229
+ Detect language for the given text. Returns ISO-ish language code and English language name.
230
+ """
231
+ if not text or not text.strip():
232
+ return {"code": "en", "name": "English"}
233
+ if not self._initialized:
234
+ return {"code": "en", "name": "English"}
235
+
236
+ try:
237
+ prompt = (
238
+ "Detect the natural language of the following user message. "
239
+ "Return strict JSON only with keys: code, name. "
240
+ "Example: {\"code\":\"es\",\"name\":\"Spanish\"}.\n\n"
241
+ f"Text:\n{text}"
242
+ )
243
+ response = self.client.models.generate_content(
244
+ model=self.model_name,
245
+ contents=prompt
246
+ )
247
+ raw = (response.text or "").strip()
248
+ match = re.search(r"\{.*\}", raw, re.DOTALL)
249
+ parsed = json.loads(match.group(0) if match else raw)
250
+ code = str(parsed.get("code", "en")).lower()
251
+ name = str(parsed.get("name", "English"))
252
+ if not code:
253
+ code = "en"
254
+ if not name:
255
+ name = "English"
256
+ return {"code": code, "name": name}
257
+ except Exception as e:
258
+ print(f"[GeminiService] Language detection error: {e}")
259
+ return {"code": "en", "name": "English"}
260
+
261
+ def translate_to_english(self, text: str, source_language: str | None = None) -> str:
262
+ """
263
+ Translate user text to English while preserving technical terms.
264
+ """
265
+ if not text or not text.strip():
266
+ return text
267
+ if not self._initialized:
268
+ return text
269
+
270
+ try:
271
+ lang_hint = f"Source language: {source_language}. " if source_language else ""
272
+ prompt = (
273
+ "Translate the following support ticket text to natural, concise English. "
274
+ "Preserve technical terms, error codes, product names, and formatting. "
275
+ "Return only translated text with no prefix or explanation. "
276
+ f"{lang_hint}\n\n"
277
+ f"Text:\n{text}"
278
+ )
279
+ response = self.client.models.generate_content(
280
+ model=self.model_name,
281
+ contents=prompt
282
+ )
283
+ translated = (response.text or "").strip()
284
+ return translated or text
285
+ except Exception as e:
286
+ print(f"[GeminiService] Translation error: {e}")
287
+ return text
backend/services/ner_service.py CHANGED
@@ -5,12 +5,20 @@ Labels follow pattern: B-B-ENTITY_TYPE, I-B-ENTITY_TYPE, O
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,6 +49,10 @@ class NERService:
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")):
 
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
  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")):
backend/services/rag_service.py CHANGED
@@ -1,5 +1,10 @@
1
  import os
2
- from sentence_transformers import SentenceTransformer
 
 
 
 
 
3
  from supabase import create_client, Client
4
  from dotenv import load_dotenv
5
 
@@ -27,6 +32,17 @@ class RagService:
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")
 
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
  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")
backend/services/sla_service.py CHANGED
@@ -14,6 +14,10 @@ import os
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,11 +114,12 @@ class SlaEscalationService:
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,7 +141,7 @@ class SlaEscalationService:
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,7 +174,13 @@ class SlaEscalationService:
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,6 +196,7 @@ class SlaEscalationService:
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,6 +206,31 @@ class SlaEscalationService:
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(
 
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
  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
  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
  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
 
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
  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(
backend/services/slack_notifier.py CHANGED
@@ -48,9 +48,9 @@ def build_slack_payload(ticket: dict[str, Any]) -> dict:
48
  ticket_id = str(ticket.get("id", "???"))
49
  ticket_ref = f"#T-{ticket_id[-4:]}" if len(ticket_id) >= 4 else f"#T-{ticket_id}"
50
  subject = ticket.get("subject") or "Untitled ticket"
51
- priority = str(ticket.get("priority", "unknown")).upper()
52
  assigned_team = ticket.get("assigned_team") or "Unassigned"
53
- company = ticket.get("company") or ticket.get("company_id") or "Unknown"
54
  breach_at = ticket.get("sla_breach_at") or "N/A"
55
 
56
  # Color based on priority
 
48
  ticket_id = str(ticket.get("id", "???"))
49
  ticket_ref = f"#T-{ticket_id[-4:]}" if len(ticket_id) >= 4 else f"#T-{ticket_id}"
50
  subject = ticket.get("subject") or "Untitled ticket"
51
+ priority = str(ticket.get("priority") or "unknown").upper()
52
  assigned_team = ticket.get("assigned_team") or "Unassigned"
53
+ company = ticket.get("company") or ticket.get("company_id") or "UNKNOWN"
54
  breach_at = ticket.get("sla_breach_at") or "N/A"
55
 
56
  # Color based on priority
backend/tests/conftest.py CHANGED
@@ -272,6 +272,7 @@ def mock_ai_services(request):
272
  "test_redis_cache.py",
273
  "test_semantic_duplicates.py",
274
  "test_auth_cookie.py",
 
275
  }:
276
  yield
277
  return
 
272
  "test_redis_cache.py",
273
  "test_semantic_duplicates.py",
274
  "test_auth_cookie.py",
275
+ "test_mobile_supabase_env.py",
276
  }:
277
  yield
278
  return