Spaces:
Running
Running
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +23 -0
- .gitignore +7 -0
- Dockerfile +23 -19
- Frontend/eslint.config.js +21 -0
- Frontend/package-lock.json +0 -0
- Frontend/package.json +16 -2
- Frontend/src/App.jsx +117 -117
- Frontend/src/admin/components/AdminSidebar.jsx +3 -1
- Frontend/src/admin/components/SLABadge.jsx +14 -25
- Frontend/src/admin/components/TicketTable.jsx +13 -3
- Frontend/src/admin/pages/AdminDashboard.jsx +8 -2
- Frontend/src/admin/pages/AdminSettings.jsx +111 -2
- Frontend/src/admin/pages/AdminTicketDetail.jsx +24 -1
- Frontend/src/admin/pages/AdminTickets.jsx +36 -37
- Frontend/src/components/shared/BugReportWidget.jsx +0 -12
- Frontend/src/config.js +2 -1
- Frontend/src/legacy_ui/Dashboard.jsx +2 -2
- Frontend/src/pages/AdminSignup.jsx +18 -3
- Frontend/src/pages/LandingPage.jsx +44 -41
- Frontend/src/pages/Signup.jsx +19 -4
- Frontend/src/services/aiAssistant.js +88 -69
- Frontend/src/services/api.js +50 -19
- Frontend/src/store/authStore.js +59 -2
- Frontend/src/store/ticketStore.js +25 -14
- Frontend/src/user/components/RecentTickets.jsx +5 -0
- Frontend/src/user/pages/AutoResolveChat.jsx +56 -2
- Frontend/src/user/pages/CreateTicket.jsx +1 -1
- Frontend/src/user/pages/MyTickets.jsx +13 -0
- Frontend/src/user/pages/TicketDetail.jsx +26 -0
- Frontend/vite.config.js +1 -1
- MobileApp/.env.example +2 -0
- MobileApp/src/lib/supabase.js +8 -2
- MobileApp/src/screens/auth/LoginScreen.js +2 -0
- MobileApp/src/screens/user/ProfileScreen.js +3 -0
- README.md +3 -1
- backend/.env.example +101 -13
- backend/Dockerfile +23 -19
- backend/auth/crypto.py +208 -88
- backend/main.py +666 -315
- backend/requirements.txt +8 -1
- backend/services/classifier_service.py +80 -103
- backend/services/classifier_v2.py +88 -68
- backend/services/classifier_v3.py +78 -58
- backend/services/duplicate_service.py +27 -2
- backend/services/gemini_service.py +63 -0
- backend/services/ner_service.py +16 -4
- backend/services/rag_service.py +17 -1
- backend/services/sla_service.py +41 -4
- backend/services/slack_notifier.py +2 -2
- 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 |
-
#
|
| 2 |
-
FROM python:3.10-slim
|
| 3 |
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 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 |
-
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
COPY . /app/backend
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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":
|
|
|
|
|
|
|
| 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 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={<
|
| 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 |
-
<
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
<Route element={<
|
| 253 |
-
<Route
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
| 258 |
</Route>
|
| 259 |
-
</Route>
|
| 260 |
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
| 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
|
| 5 |
const SLA_LIMITS = {
|
| 6 |
-
critical:
|
| 7 |
-
high:
|
| 8 |
-
medium:
|
| 9 |
-
low:
|
| 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,
|
| 33 |
const [remaining, setRemaining] = useState(null);
|
| 34 |
|
| 35 |
-
const
|
| 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
|
| 47 |
const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
|
| 48 |
-
const
|
| 49 |
-
? new Date(slaBreachAt).getTime()
|
| 50 |
-
: new Date(createdAt).getTime() + limit;
|
| 51 |
-
|
| 52 |
-
if (!Number.isFinite(deadlineMs)) return;
|
| 53 |
|
| 54 |
const calculate = () => {
|
| 55 |
-
const
|
|
|
|
| 56 |
setRemaining(rem);
|
| 57 |
};
|
| 58 |
|
| 59 |
calculate();
|
| 60 |
const timer = setInterval(calculate, 60 * 1000); // update every minute
|
| 61 |
return () => clearInterval(timer);
|
| 62 |
-
}, [priority, createdAt,
|
| 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 |
-
<
|
| 133 |
-
{
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"{
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
// Detect newly inserted or updated tickets for highlight animation
|
|
@@ -84,7 +84,7 @@ const Dashboard = () => {
|
|
| 84 |
});
|
| 85 |
|
| 86 |
prevTicketsRef.current = tickets;
|
| 87 |
-
|
| 88 |
}, [tickets]);
|
| 89 |
|
| 90 |
// Summary Counts
|
|
|
|
| 63 |
}
|
| 64 |
};
|
| 65 |
fetchTickets();
|
| 66 |
+
|
| 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 (
|
| 84 |
-
setError(
|
| 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-
|
| 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=
|
| 128 |
>
|
| 129 |
-
Start Free <ArrowRight className=
|
| 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:
|
| 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:
|
| 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:
|
| 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=
|
| 320 |
>
|
| 321 |
-
<Play className=
|
| 322 |
</button>
|
| 323 |
<button
|
| 324 |
onClick={() => navigate('/admin-signup')}
|
| 325 |
-
className=
|
| 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=
|
| 349 |
-
<Play className=
|
| 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=
|
| 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=
|
| 386 |
>
|
| 387 |
-
Get Started Free <ArrowRight className=
|
| 388 |
</button>
|
| 389 |
<button
|
| 390 |
onClick={() => setShowDemo(true)}
|
| 391 |
-
className=
|
| 392 |
>
|
| 393 |
-
<Play className=
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 679 |
{step.num}
|
| 680 |
</div>
|
| 681 |
<div>
|
| 682 |
-
<h3 className={`text-xl font-
|
| 683 |
{step.title}
|
| 684 |
</h3>
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 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="
|
| 705 |
<motion.div
|
| 706 |
key={activeStep}
|
| 707 |
-
initial={{ opacity: 0, scale: 0.
|
| 708 |
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 709 |
-
exit={{ opacity: 0, scale: 1.
|
| 710 |
-
transition={{
|
| 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
|
| 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=
|
| 819 |
>
|
| 820 |
Get Started Free
|
| 821 |
</button>
|
| 822 |
<button
|
| 823 |
onClick={() => setShowDemo(true)}
|
| 824 |
-
className=
|
| 825 |
>
|
| 826 |
-
<Play className=
|
| 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 (
|
| 117 |
-
setError(
|
| 118 |
return;
|
| 119 |
}
|
| 120 |
|
| 121 |
-
if (
|
| 122 |
-
setError(
|
| 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 {
|
| 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 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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 |
-
|
| 38 |
-
|
| 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 |
-
|
| 49 |
-
configs.push({ provider: 'groq',
|
| 50 |
});
|
| 51 |
|
| 52 |
return configs;
|
|
@@ -57,11 +41,8 @@ const buildConfigList = () => {
|
|
| 57 |
// PROVIDER HANDLERS
|
| 58 |
// ============================================================
|
| 59 |
|
| 60 |
-
const
|
| 61 |
-
const
|
| 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 |
-
|
| 87 |
-
|
|
|
|
| 88 |
};
|
| 89 |
|
| 90 |
-
const
|
| 91 |
-
const messages = history.map(msg =>
|
| 92 |
-
|
| 93 |
-
|
| 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 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
if (
|
| 109 |
-
|
| 110 |
-
err.status = response.status;
|
| 111 |
-
throw err;
|
| 112 |
}
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 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 |
-
//
|
| 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
|
| 31 |
};
|
| 32 |
}),
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 =
|
| 6 |
-
const supabaseKey =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
#
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 11 |
-
#
|
| 12 |
-
#
|
| 13 |
-
#
|
|
|
|
|
|
|
| 14 |
SENTENCE_TRANSFORMER_MODEL_PATH=
|
| 15 |
|
| 16 |
-
|
| 17 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 2 |
-
FROM python:3.10-slim
|
| 3 |
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 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 |
-
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
COPY . /app/backend
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
try:
|
| 22 |
-
#
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
print(f"[Crypto Error] Encryption failed: {e}")
|
| 30 |
-
return plain_text
|
| 31 |
|
| 32 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
try:
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
|
| 54 |
-
def
|
| 55 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
try:
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
#
|
| 40 |
-
|
| 41 |
-
|
| 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 |
-
|
|
|
|
| 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.
|
| 72 |
-
|
| 73 |
-
|
| 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 |
-
|
| 277 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
print(f"[WARNING] Classifier not loaded: {e}")
|
| 279 |
try:
|
| 280 |
ner_service.load()
|
| 281 |
-
except
|
| 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 |
-
|
| 298 |
-
|
| 299 |
-
|
| 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 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 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.
|
| 481 |
-
"rag_loaded": rag_service.
|
| 482 |
}
|
| 483 |
if require_supabase:
|
| 484 |
checks["supabase_configured"] = supabase is not None
|
| 485 |
|
| 486 |
-
|
| 487 |
-
|
| 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 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 767 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
|
|
|
| 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"
|
| 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 |
-
|
| 799 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
if duplicate_text:
|
| 801 |
try:
|
|
|
|
| 802 |
duplicate_service.add_ticket(str(ticket_id), duplicate_text)
|
|
|
|
|
|
|
| 803 |
except Exception as index_error:
|
| 804 |
-
|
| 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":
|
| 829 |
-
"is_potential_duplicate": final_data["is_potential_duplicate"],
|
| 830 |
-
"parent_ticket_id": final_data["parent_ticket_id"],
|
| 831 |
}
|
| 832 |
-
if
|
| 833 |
-
response["
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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
|
| 1104 |
-
|
|
|
|
|
|
|
| 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"] <
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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"] <
|
| 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 |
-
"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 10 |
-
import torch
|
| 11 |
-
|
| 12 |
-
import
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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(
|
| 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 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
if
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
|
| 30 |
-
class ClassifierServiceV2:
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
|
|
|
|
| 87 |
|
| 88 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
nn.
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
-
class ClassifierServiceV3:
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 9 |
-
import torch
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 52 |
assigned_team = ticket.get("assigned_team") or "Unassigned"
|
| 53 |
-
company = ticket.get("company") or ticket.get("company_id") or "
|
| 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
|