Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +0 -23
- .gitignore +1 -11
- Dockerfile +19 -23
- Frontend/eslint.config.js +0 -21
- Frontend/package-lock.json +0 -0
- Frontend/package.json +3 -17
- Frontend/src/App.jsx +117 -117
- Frontend/src/admin/components/AdminSidebar.jsx +1 -3
- Frontend/src/admin/components/SLABadge.jsx +25 -14
- Frontend/src/admin/components/TicketTable.jsx +3 -13
- Frontend/src/admin/pages/AdminDashboard.jsx +19 -48
- Frontend/src/admin/pages/AdminSettings.jsx +2 -111
- Frontend/src/admin/pages/AdminTicketDetail.jsx +1 -24
- Frontend/src/admin/pages/AdminTickets.jsx +37 -36
- Frontend/src/components/shared/BugReportWidget.jsx +12 -0
- Frontend/src/config.js +1 -2
- Frontend/src/legacy_ui/Dashboard.jsx +2 -2
- Frontend/src/pages/AdminSignup.jsx +3 -18
- Frontend/src/pages/LandingPage.jsx +41 -44
- Frontend/src/pages/Signup.jsx +4 -19
- Frontend/src/services/aiAssistant.js +69 -88
- Frontend/src/services/api.js +19 -50
- Frontend/src/store/authStore.js +31 -87
- Frontend/src/store/ticketStore.js +14 -60
- Frontend/src/user/components/RecentTickets.jsx +0 -5
- Frontend/src/user/pages/AutoResolveChat.jsx +2 -56
- Frontend/src/user/pages/CreateTicket.jsx +1 -1
- Frontend/src/user/pages/MyTickets.jsx +0 -13
- Frontend/src/user/pages/TicketDetail.jsx +0 -26
- Frontend/vite.config.js +1 -1
- MobileApp/App.js +18 -77
- MobileApp/package-lock.json +1 -1
- MobileApp/package.json +1 -1
- MobileApp/src/lib/supabase.js +2 -8
- MobileApp/src/screens/auth/LoginScreen.js +0 -2
- MobileApp/src/screens/user/ProfileScreen.js +0 -3
- README.md +1 -3
- backend/.env.example +13 -101
- backend/Dockerfile +19 -23
- backend/auth/crypto.py +88 -208
- backend/main.py +360 -1016
- backend/requirements.txt +1 -10
- backend/services/classifier_service.py +97 -95
- backend/services/classifier_v2.py +68 -88
- backend/services/classifier_v3.py +58 -78
- backend/services/duplicate_service.py +2 -27
- backend/services/gemini_service.py +7 -85
- backend/services/ner_service.py +4 -16
- backend/services/rag_service.py +1 -17
- backend/services/sla_service.py +4 -41
.gitattributes
CHANGED
|
@@ -2,29 +2,6 @@
|
|
| 2 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
.tmp-ci-venv/Lib/site-packages/dateutil/zoneinfo/dateutil-zoneinfo.tar.gz filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/t64-arm.exe filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/t64.exe filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/w64-arm.exe filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/w64.exe filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
.tmp-ci-venv/Lib/site-packages/setuptools/cli-arm64.exe filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
.tmp-ci-venv/Lib/site-packages/setuptools/gui-arm64.exe filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
.tmp-ci-venv/Scripts/dotenv.exe filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
.tmp-ci-venv/Scripts/fastapi.exe filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
.tmp-ci-venv/Scripts/httpx.exe filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
.tmp-ci-venv/Scripts/idna.exe filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
.tmp-ci-venv/Scripts/markdown-it.exe filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
.tmp-ci-venv/Scripts/normalizer.exe filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
.tmp-ci-venv/Scripts/pip.exe filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
.tmp-ci-venv/Scripts/pip3.11.exe filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
.tmp-ci-venv/Scripts/pip3.exe filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
.tmp-ci-venv/Scripts/pygmentize.exe filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
.tmp-ci-venv/Scripts/pyiceberg.exe filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
.tmp-ci-venv/Scripts/python.exe filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
.tmp-ci-venv/Scripts/pythonw.exe filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
.tmp-ci-venv/Scripts/uvicorn.exe filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
.tmp-ci-venv/Scripts/watchfiles.exe filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
.tmp-ci-venv/Scripts/websockets.exe filter=lfs diff=lfs merge=lfs -text
|
| 28 |
Frontend/public/favicon.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 29 |
Frontend/public/favicon.png filter=lfs diff=lfs merge=lfs -text
|
| 30 |
Frontend/public/team/pragati_tiwari.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 2 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
Frontend/public/favicon.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
Frontend/public/favicon.png filter=lfs diff=lfs merge=lfs -text
|
| 7 |
Frontend/public/team/pragati_tiwari.jpg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -4,10 +4,7 @@ __pycache__/
|
|
| 4 |
*$py.class
|
| 5 |
venv/
|
| 6 |
env/
|
| 7 |
-
|
| 8 |
-
**/.env
|
| 9 |
-
**/.env.*
|
| 10 |
-
|
| 11 |
|
| 12 |
# Models (Keep them if they are small, but here they are large.
|
| 13 |
# We should probably only keep the ones we actually use if possible,
|
|
@@ -37,10 +34,3 @@ docs/logs/
|
|
| 37 |
scratch/
|
| 38 |
.npm-cache/
|
| 39 |
|
| 40 |
-
# GSSoC Score Tracking and Temporary Scripts
|
| 41 |
-
scratch/
|
| 42 |
-
.npm-cache/
|
| 43 |
-
*.tmp
|
| 44 |
-
../MOBILE_SETUP_TEMP.md
|
| 45 |
-
../CI_YML_TEMP.yml
|
| 46 |
-
../ENV_EXAMPLE_TEMP.example
|
|
|
|
| 4 |
*$py.class
|
| 5 |
venv/
|
| 6 |
env/
|
| 7 |
+
.env
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
# Models (Keep them if they are small, but here they are large.
|
| 10 |
# We should probably only keep the ones we actually use if possible,
|
|
|
|
| 34 |
scratch/
|
| 35 |
.npm-cache/
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -1,40 +1,36 @@
|
|
| 1 |
-
#
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 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 |
-
|
| 30 |
-
|
| 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 |
-
|
| 39 |
-
|
| 40 |
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
|
| 4 |
+
LABEL version="1.1.1" rebuild_trigger="2026-03-08-2032"
|
| 5 |
|
| 6 |
+
# Set the working directory to /app
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Install system dependencies required for EasyOCR and OpenCV
|
| 10 |
+
RUN apt-get update && apt-get install -y \
|
| 11 |
+
libgl1 \
|
| 12 |
+
libglib2.0-0 \
|
| 13 |
git \
|
| 14 |
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
|
| 16 |
+
# Copy the requirements file into the container
|
| 17 |
COPY requirements.txt .
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
# Install dependencies (no-cache-dir keeps the docker image smaller)
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
|
| 22 |
+
# Copy all the remaining files into the container as a 'backend' directory
|
| 23 |
+
# This allows absolute imports like 'from backend.services...' to work perfectly
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
COPY . /app/backend
|
| 25 |
|
| 26 |
+
# Tell Python where to look for modules (so it can find the 'backend' folder)
|
| 27 |
+
ENV PYTHONPATH=/app
|
|
|
|
| 28 |
|
| 29 |
+
# Expose port 7860 (Hugging Face Spaces default)
|
| 30 |
EXPOSE 7860
|
| 31 |
|
| 32 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
| 33 |
CMD ["python", "backend/healthcheck.py"]
|
| 34 |
|
| 35 |
+
# Run the FastAPI server via Uvicorn
|
|
|
|
| 36 |
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
Frontend/eslint.config.js
CHANGED
|
@@ -35,25 +35,4 @@ export default defineConfig([
|
|
| 35 |
'react-refresh/only-export-components': 'off',
|
| 36 |
},
|
| 37 |
},
|
| 38 |
-
{
|
| 39 |
-
files: [
|
| 40 |
-
'**/*.{test,spec}.{js,jsx}',
|
| 41 |
-
'**/__tests__/**/*.{js,jsx}',
|
| 42 |
-
'jest.setup.js',
|
| 43 |
-
'jest.fileMock.js',
|
| 44 |
-
],
|
| 45 |
-
languageOptions: {
|
| 46 |
-
globals: {
|
| 47 |
-
...globals.browser,
|
| 48 |
-
...globals.node,
|
| 49 |
-
...globals.jest,
|
| 50 |
-
},
|
| 51 |
-
},
|
| 52 |
-
},
|
| 53 |
-
{
|
| 54 |
-
files: ['jest.fileMock.js'],
|
| 55 |
-
languageOptions: {
|
| 56 |
-
sourceType: 'commonjs',
|
| 57 |
-
},
|
| 58 |
-
},
|
| 59 |
])
|
|
|
|
| 35 |
'react-refresh/only-export-components': 'off',
|
| 36 |
},
|
| 37 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
])
|
Frontend/package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Frontend/package.json
CHANGED
|
@@ -6,12 +6,10 @@
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
-
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
| 10 |
-
"check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
|
| 11 |
"preview": "vite preview",
|
| 12 |
"prepare": "husky install",
|
| 13 |
-
"format": "prettier --write ."
|
| 14 |
-
"test": "jest"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
"@google/generative-ai": "^0.24.1",
|
|
@@ -44,18 +42,11 @@
|
|
| 44 |
"zustand": "^5.0.11"
|
| 45 |
},
|
| 46 |
"devDependencies": {
|
| 47 |
-
"@babel/core": "^7.24.0",
|
| 48 |
-
"@babel/preset-env": "^7.24.0",
|
| 49 |
-
"@babel/preset-react": "^7.24.0",
|
| 50 |
"@eslint/js": "^9.39.1",
|
| 51 |
-
"@testing-library/dom": "^10.4.1",
|
| 52 |
-
"@testing-library/jest-dom": "^6.6.3",
|
| 53 |
-
"@testing-library/react": "^16.2.0",
|
| 54 |
"@types/react": "^19.2.7",
|
| 55 |
"@types/react-dom": "^19.2.3",
|
| 56 |
"@vitejs/plugin-react": "^5.1.1",
|
| 57 |
"autoprefixer": "^10.4.24",
|
| 58 |
-
"babel-jest": "^29.7.0",
|
| 59 |
"eslint": "^9.39.2",
|
| 60 |
"eslint-config-prettier": "^10.1.8",
|
| 61 |
"eslint-plugin-react": "^7.37.5",
|
|
@@ -63,16 +54,11 @@
|
|
| 63 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 64 |
"globals": "^16.5.0",
|
| 65 |
"husky": "^9.1.7",
|
| 66 |
-
"identity-obj-proxy": "^3.0.0",
|
| 67 |
-
"jest": "^29.7.0",
|
| 68 |
-
"jest-environment-jsdom": "^29.7.0",
|
| 69 |
"lint-staged": "^16.2.7",
|
| 70 |
"postcss": "^8.5.6",
|
| 71 |
"prettier": "^3.8.1",
|
| 72 |
"tailwindcss": "^3.4.19",
|
| 73 |
"vite": "^7.3.1"
|
| 74 |
},
|
| 75 |
-
"lint-staged": {
|
| 76 |
-
"*.{js,jsx}": ["eslint --fix", "prettier --write"]
|
| 77 |
-
}
|
| 78 |
}
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
+
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
|
|
|
| 10 |
"preview": "vite preview",
|
| 11 |
"prepare": "husky install",
|
| 12 |
+
"format": "prettier --write ."
|
|
|
|
| 13 |
},
|
| 14 |
"dependencies": {
|
| 15 |
"@google/generative-ai": "^0.24.1",
|
|
|
|
| 42 |
"zustand": "^5.0.11"
|
| 43 |
},
|
| 44 |
"devDependencies": {
|
|
|
|
|
|
|
|
|
|
| 45 |
"@eslint/js": "^9.39.1",
|
|
|
|
|
|
|
|
|
|
| 46 |
"@types/react": "^19.2.7",
|
| 47 |
"@types/react-dom": "^19.2.3",
|
| 48 |
"@vitejs/plugin-react": "^5.1.1",
|
| 49 |
"autoprefixer": "^10.4.24",
|
|
|
|
| 50 |
"eslint": "^9.39.2",
|
| 51 |
"eslint-config-prettier": "^10.1.8",
|
| 52 |
"eslint-plugin-react": "^7.37.5",
|
|
|
|
| 54 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 55 |
"globals": "^16.5.0",
|
| 56 |
"husky": "^9.1.7",
|
|
|
|
|
|
|
|
|
|
| 57 |
"lint-staged": "^16.2.7",
|
| 58 |
"postcss": "^8.5.6",
|
| 59 |
"prettier": "^3.8.1",
|
| 60 |
"tailwindcss": "^3.4.19",
|
| 61 |
"vite": "^7.3.1"
|
| 62 |
},
|
| 63 |
+
"lint-staged": "{'*.{js,jsx}': ['eslint --fix', 'prettier --write']}"
|
|
|
|
|
|
|
| 64 |
}
|
Frontend/src/App.jsx
CHANGED
|
@@ -5,81 +5,80 @@ import {
|
|
| 5 |
Navigate,
|
| 6 |
useLocation
|
| 7 |
} from "react-router-dom";
|
| 8 |
-
import React, {
|
|
|
|
|
|
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
const DuplicateDetection = lazy(() => import("./user/pages/DuplicateDetection"));
|
| 27 |
-
const AutoResolveChat = lazy(() => import("./user/pages/AutoResolveChat"));
|
| 28 |
-
const Resolved = lazy(() => import("./user/pages/Resolved"));
|
| 29 |
-
const TicketTracking = lazy(() => import("./user/pages/TicketTracking"));
|
| 30 |
-
|
| 31 |
-
const UserLayout = lazy(() => import("./user/UserLayout"));
|
| 32 |
-
const AdminLayout = lazy(() => import("./admin/layout/AdminLayout"));
|
| 33 |
-
|
| 34 |
-
const Dashboard = lazy(() => import("./user/pages/Dashboard"));
|
| 35 |
-
const CreateTicket = lazy(() => import("./user/pages/CreateTicket"));
|
| 36 |
-
const MyTickets = lazy(() => import("./user/pages/MyTickets"));
|
| 37 |
-
const TicketResult = lazy(() => import("./user/pages/TicketResult"));
|
| 38 |
-
const Profile = lazy(() => import("./user/pages/Profile"));
|
| 39 |
-
const TicketDetail = lazy(() => import("./user/pages/TicketDetail"));
|
| 40 |
-
const AIProcessing = lazy(() => import("./user/pages/AIProcessing"));
|
| 41 |
-
const AIUnderstanding = lazy(() => import("./user/pages/AIUnderstanding"));
|
| 42 |
-
const Notifications = lazy(() => import("./user/pages/Notifications"));
|
| 43 |
-
const Help = lazy(() => import("./user/pages/Help"));
|
| 44 |
-
|
| 45 |
-
const AdminDashboard = lazy(() => import("./admin/pages/AdminDashboard"));
|
| 46 |
-
const AdminTickets = lazy(() => import("./admin/pages/AdminTickets"));
|
| 47 |
-
const AdminTicketDetail = lazy(() => import("./admin/pages/AdminTicketDetail"));
|
| 48 |
-
const AdminUsers = lazy(() => import("./admin/pages/AdminUsers"));
|
| 49 |
-
const AdminAnalytics = lazy(() => import("./admin/pages/AdminAnalytics"));
|
| 50 |
-
const AdminProfile = lazy(() => import("./admin/pages/AdminProfile"));
|
| 51 |
-
const AdminSettings = lazy(() => import("./admin/pages/AdminSettings"));
|
| 52 |
-
const SLAPage = lazy(() => import("./admin/pages/SLAPage"));
|
| 53 |
-
const MasterBugReports = lazy(() => import("./master-admin/pages/MasterBugReports"));
|
| 54 |
-
|
| 55 |
-
const AutoCategorizationFeature = lazy(() => import("./pages/features/AutoCategorizationFeature"));
|
| 56 |
-
const PriorityDetectionFeature = lazy(() => import("./pages/features/PriorityDetectionFeature"));
|
| 57 |
-
const SmartResolutionFeature = lazy(() => import("./pages/features/SmartResolutionFeature"));
|
| 58 |
-
|
| 59 |
-
const TermsOfService = lazy(() => import("./pages/legal/TermsOfService"));
|
| 60 |
-
const PrivacyPolicy = lazy(() => import("./pages/legal/PrivacyPolicy"));
|
| 61 |
-
const Security = lazy(() => import("./pages/legal/Security"));
|
| 62 |
-
|
| 63 |
-
const MasterAdminLogin = lazy(() => import("./pages/MasterAdminLogin"));
|
| 64 |
-
const MasterAdminLayout = lazy(() => import("./master-admin/layout/MasterAdminLayout"));
|
| 65 |
-
const MasterAdminDashboard = lazy(() => import("./master-admin/pages/MasterAdminDashboard"));
|
| 66 |
-
const PendingAdminRequests = lazy(() => import("./master-admin/pages/PendingAdminRequests"));
|
| 67 |
-
const AllCompanies = lazy(() => import("./master-admin/pages/AllCompanies"));
|
| 68 |
-
const AllAdmins = lazy(() => import("./master-admin/pages/AllAdmins"));
|
| 69 |
-
const Changelog = lazy(() => import("./pages/Changelog"));
|
| 70 |
-
const NotFoundPage = lazy(() => import("./components/ui/not-found-2").then((module) => ({ default: module.NotFound })));
|
| 71 |
-
const Toaster = lazy(() => import("./components/shared/Toaster"));
|
| 72 |
-
const BugReportWidget = lazy(() => import("./components/shared/BugReportWidget"));
|
| 73 |
-
|
| 74 |
-
function RouteFallback() {
|
| 75 |
-
return (
|
| 76 |
-
<div className="flex min-h-[40vh] items-center justify-center px-6 py-16">
|
| 77 |
-
<div className="rounded-2xl border border-slate-200 bg-white px-6 py-4 text-sm font-semibold text-slate-500 shadow-sm">
|
| 78 |
-
Loading...
|
| 79 |
-
</div>
|
| 80 |
-
</div>
|
| 81 |
-
);
|
| 82 |
-
}
|
| 83 |
|
| 84 |
|
| 85 |
function TitleUpdater() {
|
|
@@ -97,7 +96,6 @@ function TitleUpdater() {
|
|
| 97 |
else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
|
| 98 |
else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
|
| 99 |
else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
|
| 100 |
-
else if (path.startsWith('/admin/sla')) title = 'SLA Monitor | Admin';
|
| 101 |
// Master Admin Routes
|
| 102 |
else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
|
| 103 |
else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
|
|
@@ -193,11 +191,10 @@ function AppLayout() {
|
|
| 193 |
<Route path="/admin/analytics" element={<AdminAnalytics />} />
|
| 194 |
<Route path="/admin/profile" element={<AdminProfile />} />
|
| 195 |
<Route path="/admin/settings" element={<AdminSettings />} />
|
| 196 |
-
<Route path="/admin/sla" element={<SLAPage />} />
|
| 197 |
</Route>
|
| 198 |
</Route>
|
| 199 |
|
| 200 |
-
<Route path="*" element={<
|
| 201 |
</Routes>
|
| 202 |
</>
|
| 203 |
);
|
|
@@ -209,60 +206,63 @@ function App() {
|
|
| 209 |
|
| 210 |
useEffect(() => {
|
| 211 |
initialize();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
}, [initialize]);
|
| 213 |
|
| 214 |
return (
|
| 215 |
<BrowserRouter>
|
| 216 |
<TitleUpdater />
|
| 217 |
<ScrollToTop />
|
| 218 |
-
<
|
| 219 |
-
|
| 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 |
-
<Route element={<
|
| 251 |
-
<Route element={<
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
<Route path="/master-admin/bug-reports" element={<MasterBugReports />} />
|
| 257 |
-
</Route>
|
| 258 |
</Route>
|
|
|
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
</Suspense>
|
| 266 |
</BrowserRouter>
|
| 267 |
);
|
| 268 |
}
|
|
|
|
| 5 |
Navigate,
|
| 6 |
useLocation
|
| 7 |
} from "react-router-dom";
|
| 8 |
+
import React, { useEffect } from "react";
|
| 9 |
+
import { AnimatePresence } from "framer-motion";
|
| 10 |
+
import { NotFound } from "./components/ui/not-found-2";
|
| 11 |
import useTicketStore from "./store/ticketStore";
|
| 12 |
+
import Toaster from "./components/shared/Toaster";
|
| 13 |
+
import BugReportWidget from "./components/shared/BugReportWidget";
|
| 14 |
import useRealtimeNotifications from "./hooks/useRealtimeNotifications";
|
| 15 |
+
|
| 16 |
+
// Auth Components
|
| 17 |
+
import Login from "./pages/Login";
|
| 18 |
+
import ForgotPassword from "./pages/ForgotPassword";
|
| 19 |
+
import ResetPassword from "./pages/ResetPassword";
|
| 20 |
+
import Signup from "./pages/Signup";
|
| 21 |
+
import AdminSignup from "./pages/AdminSignup";
|
| 22 |
+
import AdminLobby from "./pages/AdminLobby";
|
| 23 |
+
import UserLobby from "./pages/UserLobby";
|
| 24 |
+
import LandingPage from "./pages/LandingPage";
|
| 25 |
+
import ContactSales from "./pages/ContactSales";
|
| 26 |
+
|
| 27 |
+
// Legacy components
|
| 28 |
+
import DuplicateDetection from "./user/pages/DuplicateDetection";
|
| 29 |
+
import AutoResolveChat from "./user/pages/AutoResolveChat";
|
| 30 |
+
import Resolved from "./user/pages/Resolved";
|
| 31 |
+
import TicketTracking from "./user/pages/TicketTracking";
|
| 32 |
+
// Layouts
|
| 33 |
+
import UserLayout from "./user/UserLayout";
|
| 34 |
+
import AdminLayout from "./admin/layout/AdminLayout";
|
| 35 |
+
|
| 36 |
+
// User Pages
|
| 37 |
+
import Dashboard from "./user/pages/Dashboard";
|
| 38 |
+
import CreateTicket from "./user/pages/CreateTicket";
|
| 39 |
+
import MyTickets from "./user/pages/MyTickets";
|
| 40 |
+
import TicketResult from "./user/pages/TicketResult";
|
| 41 |
+
import Profile from "./user/pages/Profile";
|
| 42 |
+
import TicketDetail from "./user/pages/TicketDetail";
|
| 43 |
+
import TicketProcessing from "./user/pages/AIProcessing"; // Renamed generic import just in case, but keeping AIProcessing
|
| 44 |
+
import AIProcessing from "./user/pages/AIProcessing";
|
| 45 |
+
import AIUnderstanding from "./user/pages/AIUnderstanding";
|
| 46 |
+
import Notifications from "./user/pages/Notifications";
|
| 47 |
+
import Help from "./user/pages/Help";
|
| 48 |
+
|
| 49 |
+
// NEW Admin Pages (Refactored)
|
| 50 |
+
import AdminDashboard from "./admin/pages/AdminDashboard";
|
| 51 |
+
import AdminTickets from "./admin/pages/AdminTickets";
|
| 52 |
+
import AdminTicketDetail from "./admin/pages/AdminTicketDetail";
|
| 53 |
+
import AdminUsers from "./admin/pages/AdminUsers";
|
| 54 |
+
import AdminAnalytics from "./admin/pages/AdminAnalytics";
|
| 55 |
+
import AdminProfile from "./admin/pages/AdminProfile";
|
| 56 |
+
import AdminSettings from "./admin/pages/AdminSettings";
|
| 57 |
+
import MasterBugReports from "./master-admin/pages/MasterBugReports";
|
| 58 |
+
|
| 59 |
+
// Feature Pages
|
| 60 |
+
import AutoCategorizationFeature from "./pages/features/AutoCategorizationFeature";
|
| 61 |
+
import PriorityDetectionFeature from "./pages/features/PriorityDetectionFeature";
|
| 62 |
+
import SmartResolutionFeature from "./pages/features/SmartResolutionFeature";
|
| 63 |
+
|
| 64 |
+
// Legal Pages
|
| 65 |
+
import TermsOfService from "./pages/legal/TermsOfService";
|
| 66 |
+
import PrivacyPolicy from "./pages/legal/PrivacyPolicy";
|
| 67 |
+
import Security from "./pages/legal/Security";
|
| 68 |
import AdminProtectedRoute from "./components/shared/AdminProtectedRoute";
|
| 69 |
import MasterAdminProtectedRoute from "./components/shared/MasterAdminProtectedRoute";
|
| 70 |
import ProtectedRoute from "./components/shared/ProtectedRoute";
|
| 71 |
import useAuthStore from "./store/authStore";
|
| 72 |
import NotApproved from "./pages/NotApproved";
|
| 73 |
+
|
| 74 |
+
// Master Admin Components
|
| 75 |
+
import MasterAdminLogin from "./pages/MasterAdminLogin";
|
| 76 |
+
import MasterAdminLayout from "./master-admin/layout/MasterAdminLayout";
|
| 77 |
+
import MasterAdminDashboard from "./master-admin/pages/MasterAdminDashboard";
|
| 78 |
+
import PendingAdminRequests from "./master-admin/pages/PendingAdminRequests";
|
| 79 |
+
import AllCompanies from "./master-admin/pages/AllCompanies";
|
| 80 |
+
import AllAdmins from "./master-admin/pages/AllAdmins";
|
| 81 |
+
import Changelog from "./pages/Changelog";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
function TitleUpdater() {
|
|
|
|
| 96 |
else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
|
| 97 |
else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
|
| 98 |
else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
|
|
|
|
| 99 |
// Master Admin Routes
|
| 100 |
else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
|
| 101 |
else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
|
|
|
|
| 191 |
<Route path="/admin/analytics" element={<AdminAnalytics />} />
|
| 192 |
<Route path="/admin/profile" element={<AdminProfile />} />
|
| 193 |
<Route path="/admin/settings" element={<AdminSettings />} />
|
|
|
|
| 194 |
</Route>
|
| 195 |
</Route>
|
| 196 |
|
| 197 |
+
<Route path="*" element={<NotFound />} />
|
| 198 |
</Routes>
|
| 199 |
</>
|
| 200 |
);
|
|
|
|
| 206 |
|
| 207 |
useEffect(() => {
|
| 208 |
initialize();
|
| 209 |
+
// Dark mode initialize
|
| 210 |
+
const saved = localStorage.getItem('theme');
|
| 211 |
+
if (saved === 'dark') {
|
| 212 |
+
document.documentElement.classList.add('dark');
|
| 213 |
+
}
|
| 214 |
}, [initialize]);
|
| 215 |
|
| 216 |
return (
|
| 217 |
<BrowserRouter>
|
| 218 |
<TitleUpdater />
|
| 219 |
<ScrollToTop />
|
| 220 |
+
<Toaster />
|
| 221 |
+
<BugReportWidget />
|
| 222 |
+
<Routes>
|
| 223 |
+
{/* Public */}
|
| 224 |
+
<Route path="/" element={<LandingPage />} />
|
| 225 |
+
<Route path="/login" element={<Login />} />
|
| 226 |
+
<Route path="/forgot-password" element={<ForgotPassword />} />
|
| 227 |
+
<Route path="/reset-password" element={<ResetPassword />} />
|
| 228 |
+
<Route path="/signup" element={<Signup />} />
|
| 229 |
+
<Route path="/admin-signup" element={<AdminSignup />} />
|
| 230 |
+
<Route path="/admin-lobby" element={<AdminLobby />} />
|
| 231 |
+
<Route path="/user-lobby" element={<UserLobby />} />
|
| 232 |
+
<Route path="/not-approved" element={<NotApproved />} />
|
| 233 |
+
<Route path="/contact-sales" element={<ContactSales />} />
|
| 234 |
+
|
| 235 |
+
{/* Feature Pages */}
|
| 236 |
+
<Route path="/features/categorization" element={<AutoCategorizationFeature />} />
|
| 237 |
+
<Route path="/features/priority" element={<PriorityDetectionFeature />} />
|
| 238 |
+
<Route path="/features/resolution" element={<SmartResolutionFeature />} />
|
| 239 |
+
|
| 240 |
+
{/* Resources Pages */}
|
| 241 |
+
<Route path="/changelog" element={<Changelog />} />
|
| 242 |
+
|
| 243 |
+
{/* Legal Pages */}
|
| 244 |
+
<Route path="/terms" element={<TermsOfService />} />
|
| 245 |
+
<Route path="/privacy" element={<PrivacyPolicy />} />
|
| 246 |
+
<Route path="/security" element={<Security />} />
|
| 247 |
+
|
| 248 |
+
{/* Master Admin Portal */}
|
| 249 |
+
<Route path="/master-admin-login" element={<MasterAdminLogin />} />
|
| 250 |
+
|
| 251 |
+
<Route element={<MasterAdminProtectedRoute />}>
|
| 252 |
+
<Route element={<MasterAdminLayout />}>
|
| 253 |
+
<Route path="/master-admin/dashboard" element={<MasterAdminDashboard />} />
|
| 254 |
+
<Route path="/master-admin/admin-requests" element={<PendingAdminRequests />} />
|
| 255 |
+
<Route path="/master-admin/companies" element={<AllCompanies />} />
|
| 256 |
+
<Route path="/master-admin/all-admins" element={<AllAdmins />} />
|
| 257 |
+
<Route path="/master-admin/bug-reports" element={<MasterBugReports />} />
|
|
|
|
|
|
|
| 258 |
</Route>
|
| 259 |
+
</Route>
|
| 260 |
|
| 261 |
+
{/* Protected */}
|
| 262 |
+
<Route element={<ProtectedRoute />}>
|
| 263 |
+
<Route path="/*" element={<AppLayout />} />
|
| 264 |
+
</Route>
|
| 265 |
+
</Routes>
|
|
|
|
| 266 |
</BrowserRouter>
|
| 267 |
);
|
| 268 |
}
|
Frontend/src/admin/components/AdminSidebar.jsx
CHANGED
|
@@ -10,8 +10,7 @@ import {
|
|
| 10 |
LogOut,
|
| 11 |
Activity,
|
| 12 |
ChevronLeft,
|
| 13 |
-
ChevronRight
|
| 14 |
-
Clock
|
| 15 |
} from 'lucide-react';
|
| 16 |
import useAuthStore from '../../store/authStore';
|
| 17 |
|
|
@@ -22,7 +21,6 @@ const AdminSidebar = ({ isMobile, onClose, isCollapsed, onToggleCollapse }) => {
|
|
| 22 |
{ label: 'Users', path: '/admin/users', icon: Users },
|
| 23 |
{ label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
| 24 |
{ label: 'Profile', path: '/admin/profile', icon: UserCircle },
|
| 25 |
-
{ label: 'SLA Monitor', path: '/admin/sla', icon: Clock },
|
| 26 |
];
|
| 27 |
|
| 28 |
const { logout } = useAuthStore();
|
|
|
|
| 10 |
LogOut,
|
| 11 |
Activity,
|
| 12 |
ChevronLeft,
|
| 13 |
+
ChevronRight
|
|
|
|
| 14 |
} from 'lucide-react';
|
| 15 |
import useAuthStore from '../../store/authStore';
|
| 16 |
|
|
|
|
| 21 |
{ label: 'Users', path: '/admin/users', icon: Users },
|
| 22 |
{ label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
| 23 |
{ label: 'Profile', path: '/admin/profile', icon: UserCircle },
|
|
|
|
| 24 |
];
|
| 25 |
|
| 26 |
const { logout } = useAuthStore();
|
Frontend/src/admin/components/SLABadge.jsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { Clock, AlertTriangle, ShieldCheck } from 'lucide-react';
|
| 3 |
|
| 4 |
-
// SLA
|
| 5 |
const SLA_LIMITS = {
|
| 6 |
-
critical:
|
| 7 |
-
high:
|
| 8 |
-
medium:
|
| 9 |
-
low:
|
| 10 |
};
|
| 11 |
|
| 12 |
function formatDuration(ms) {
|
|
@@ -19,36 +19,47 @@ 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 |
* - 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
|
|
|
|
|
|
|
| 34 |
|
| 35 |
useEffect(() => {
|
| 36 |
-
if (isResolved
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
const priorityKey = priority.toLowerCase();
|
| 39 |
const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
|
| 40 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
const calculate = () => {
|
| 43 |
-
const
|
| 44 |
-
const rem = limit - elapsed;
|
| 45 |
setRemaining(rem);
|
| 46 |
};
|
| 47 |
|
| 48 |
calculate();
|
| 49 |
const timer = setInterval(calculate, 60 * 1000); // update every minute
|
| 50 |
return () => clearInterval(timer);
|
| 51 |
-
}, [priority, createdAt, isResolved]);
|
| 52 |
|
| 53 |
if (isResolved) {
|
| 54 |
return (
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { Clock, AlertTriangle, ShieldCheck } from 'lucide-react';
|
| 3 |
|
| 4 |
+
// SLA resolution limits in milliseconds based on priority.
|
| 5 |
const SLA_LIMITS = {
|
| 6 |
+
critical: 4 * 60 * 60 * 1000,
|
| 7 |
+
high: 12 * 60 * 60 * 1000,
|
| 8 |
+
medium: 24 * 60 * 60 * 1000,
|
| 9 |
+
low: 72 * 60 * 60 * 1000,
|
| 10 |
};
|
| 11 |
|
| 12 |
function formatDuration(ms) {
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
/**
|
| 22 |
+
* SLABadge — shows SLA status for a ticket.
|
| 23 |
*
|
| 24 |
* Props:
|
| 25 |
* - priority: string ('critical' | 'high' | 'medium' | 'low')
|
| 26 |
* - createdAt: string (ISO date string)
|
| 27 |
+
* - slaBreachAt: string (ISO date string) — preferred persisted deadline
|
| 28 |
+
* - slaStatus: string ('ACTIVE' | 'WARNING' | 'BREACHED')
|
| 29 |
* - status: string — if ticket is resolved/closed, show "Met" without countdown
|
| 30 |
* - compact: bool — if true, shows just the badge with no label text
|
| 31 |
*/
|
| 32 |
+
export default function SLABadge({ priority, createdAt, slaBreachAt, slaStatus, status, compact = false }) {
|
| 33 |
const [remaining, setRemaining] = useState(null);
|
| 34 |
|
| 35 |
+
const normalizedStatus = status?.toLowerCase();
|
| 36 |
+
const normalizedSlaStatus = slaStatus?.toUpperCase();
|
| 37 |
+
const isResolved = ['resolved', 'closed', 'auto-resolved', 'auto resolved'].includes(normalizedStatus);
|
| 38 |
|
| 39 |
useEffect(() => {
|
| 40 |
+
if (isResolved) return;
|
| 41 |
+
if (normalizedSlaStatus === 'BREACHED') {
|
| 42 |
+
setRemaining(-1);
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
|
| 46 |
+
const priorityKey = priority?.toLowerCase?.() || 'medium';
|
| 47 |
const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
|
| 48 |
+
const deadlineMs = slaBreachAt
|
| 49 |
+
? new Date(slaBreachAt).getTime()
|
| 50 |
+
: new Date(createdAt).getTime() + limit;
|
| 51 |
+
|
| 52 |
+
if (!Number.isFinite(deadlineMs)) return;
|
| 53 |
|
| 54 |
const calculate = () => {
|
| 55 |
+
const rem = deadlineMs - Date.now();
|
|
|
|
| 56 |
setRemaining(rem);
|
| 57 |
};
|
| 58 |
|
| 59 |
calculate();
|
| 60 |
const timer = setInterval(calculate, 60 * 1000); // update every minute
|
| 61 |
return () => clearInterval(timer);
|
| 62 |
+
}, [priority, createdAt, slaBreachAt, normalizedSlaStatus, isResolved]);
|
| 63 |
|
| 64 |
if (isResolved) {
|
| 65 |
return (
|
Frontend/src/admin/components/TicketTable.jsx
CHANGED
|
@@ -73,9 +73,6 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
|
|
| 73 |
? ticket.assigned_team
|
| 74 |
: (teamMap[effectiveCategory] || ticket.assigned_team || 'L1 Helpdesk');
|
| 75 |
const statusSt = getStatusStyle(ticket.status);
|
| 76 |
-
const translationMeta = ticket?.metadata?.translation;
|
| 77 |
-
const isTranslated = Boolean(translationMeta?.translated);
|
| 78 |
-
const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
|
| 79 |
|
| 80 |
// Truncated subject
|
| 81 |
const subject = ticket.subject || ticket.summary || 'Untitled ticket';
|
|
@@ -132,16 +129,9 @@ const TicketTable = ({ tickets = [], isLoading = false, limit = null }) => {
|
|
| 132 |
<span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 133 |
{truncSubject}
|
| 134 |
</span>
|
| 135 |
-
<
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
</span>
|
| 139 |
-
{isTranslated && (
|
| 140 |
-
<span style={{ fontSize: '10px', color: '#0369a1' }}>
|
| 141 |
-
Translated from {sourceLanguageName}
|
| 142 |
-
</span>
|
| 143 |
-
)}
|
| 144 |
-
</div>
|
| 145 |
</div>
|
| 146 |
</div>
|
| 147 |
</td>
|
|
|
|
| 73 |
? ticket.assigned_team
|
| 74 |
: (teamMap[effectiveCategory] || ticket.assigned_team || 'L1 Helpdesk');
|
| 75 |
const statusSt = getStatusStyle(ticket.status);
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
// Truncated subject
|
| 78 |
const subject = ticket.subject || ticket.summary || 'Untitled ticket';
|
|
|
|
| 129 |
<span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 130 |
{truncSubject}
|
| 131 |
</span>
|
| 132 |
+
<span style={{ fontSize: '11px', color: '#6b7280' }}>
|
| 133 |
+
{effectiveCategory || 'General'}
|
| 134 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
</td>
|
Frontend/src/admin/pages/AdminDashboard.jsx
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
-
import { Activity, AlertTriangle, Clock, ShieldCheck
|
| 4 |
|
| 5 |
import useAuthStore from "../../store/authStore";
|
| 6 |
-
import useTicketStore from "../../store/ticketStore";
|
| 7 |
-
import useWebSocket from "../../hooks/useWebSocket";
|
| 8 |
import { supabase } from "../../lib/supabaseClient";
|
| 9 |
import StatCard from "../components/StatCard";
|
| 10 |
import TicketTable from "../components/TicketTable";
|
|
|
|
| 11 |
|
| 12 |
// Inline SVG icon components
|
| 13 |
const TicketIcon = () => (
|
|
@@ -104,31 +103,10 @@ function formatSlaCountdown(deadlineMs, nowMs) {
|
|
| 104 |
const AdminDashboard = () => {
|
| 105 |
const navigate = useNavigate();
|
| 106 |
const { profile } = useAuthStore();
|
|
|
|
| 107 |
const [isLoading, setIsLoading] = React.useState(true);
|
| 108 |
const [nowMs, setNowMs] = React.useState(() => Date.now());
|
| 109 |
|
| 110 |
-
// WebSocket connection for real-time ticket updates
|
| 111 |
-
const { isConnected: wsConnected, lastMessage } = useWebSocket(profile?.company);
|
| 112 |
-
|
| 113 |
-
// Read tickets from the Zustand store (populated below and updated by WS)
|
| 114 |
-
const tickets = useTicketStore((s) => s.tickets);
|
| 115 |
-
const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
|
| 116 |
-
const setWsConnected = useTicketStore((s) => s.setWsConnected);
|
| 117 |
-
const upsertTicket = useTicketStore((s) => s.upsertTicket);
|
| 118 |
-
|
| 119 |
-
// Sync WebSocket connection status to store
|
| 120 |
-
React.useEffect(() => {
|
| 121 |
-
setWsConnected(wsConnected);
|
| 122 |
-
}, [wsConnected, setWsConnected]);
|
| 123 |
-
|
| 124 |
-
// Route incoming WebSocket messages into the ticket store
|
| 125 |
-
React.useEffect(() => {
|
| 126 |
-
if (lastMessage) {
|
| 127 |
-
handleWsMessage(lastMessage);
|
| 128 |
-
}
|
| 129 |
-
}, [lastMessage, handleWsMessage]);
|
| 130 |
-
|
| 131 |
-
// Initial fetch — populate store from Supabase on mount
|
| 132 |
React.useEffect(() => {
|
| 133 |
if (profile) {
|
| 134 |
const fetchStats = async () => {
|
|
@@ -148,22 +126,19 @@ const AdminDashboard = () => {
|
|
| 148 |
console.warn("Retrying dashboard fetch without relation...", error);
|
| 149 |
const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
|
| 150 |
if (basicError) throw basicError;
|
| 151 |
-
|
| 152 |
-
for (const t of basicData || []) {
|
| 153 |
-
upsertTicket(t);
|
| 154 |
-
}
|
| 155 |
} else {
|
| 156 |
-
|
| 157 |
-
upsertTicket(t);
|
| 158 |
-
}
|
| 159 |
}
|
| 160 |
} catch (err) { console.error("Dashboard fetch error:", err); }
|
| 161 |
finally { setIsLoading(false); }
|
| 162 |
};
|
| 163 |
|
| 164 |
fetchStats();
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
-
}, [profile
|
| 167 |
|
| 168 |
React.useEffect(() => {
|
| 169 |
const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
|
|
@@ -219,17 +194,13 @@ const AdminDashboard = () => {
|
|
| 219 |
Dashboard
|
| 220 |
</h1>
|
| 221 |
<p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
|
| 222 |
-
{
|
| 223 |
-
|
| 224 |
-
) : (
|
| 225 |
-
<WifiOff size={14} color="#f97316" />
|
| 226 |
-
)}
|
| 227 |
-
{wsConnected ? 'WebSocket connected' : 'Reconnecting...'}
|
| 228 |
</p>
|
| 229 |
</div>
|
| 230 |
-
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background:
|
| 231 |
-
<span style={{ width: 6, height: 6, borderRadius: '50%', background:
|
| 232 |
-
<span style={{ fontSize: '11px', fontWeight: 700, color:
|
| 233 |
</div>
|
| 234 |
</div>
|
| 235 |
|
|
@@ -306,9 +277,9 @@ const AdminDashboard = () => {
|
|
| 306 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
| 307 |
AI Status
|
| 308 |
</h2>
|
| 309 |
-
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', background:
|
| 310 |
-
<span style={{ width: 5, height: 5, borderRadius: '50%', background:
|
| 311 |
-
<span style={{ fontSize: '10px', fontWeight: 700, color:
|
| 312 |
</div>
|
| 313 |
</div>
|
| 314 |
<div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
|
|
@@ -333,9 +304,9 @@ const AdminDashboard = () => {
|
|
| 333 |
<div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
|
| 334 |
<p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
|
| 335 |
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
|
| 336 |
-
|
| 337 |
-
<span style={{ fontSize: '9px', fontWeight: 600, color:
|
| 338 |
-
|
| 339 |
</span>
|
| 340 |
</div>
|
| 341 |
</div>
|
|
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { Activity, AlertTriangle, Clock, ShieldCheck } from 'lucide-react';
|
| 4 |
|
| 5 |
import useAuthStore from "../../store/authStore";
|
|
|
|
|
|
|
| 6 |
import { supabase } from "../../lib/supabaseClient";
|
| 7 |
import StatCard from "../components/StatCard";
|
| 8 |
import TicketTable from "../components/TicketTable";
|
| 9 |
+
import { formatTimelineDate } from "../../utils/dateUtils";
|
| 10 |
|
| 11 |
// Inline SVG icon components
|
| 12 |
const TicketIcon = () => (
|
|
|
|
| 103 |
const AdminDashboard = () => {
|
| 104 |
const navigate = useNavigate();
|
| 105 |
const { profile } = useAuthStore();
|
| 106 |
+
const [tickets, setTickets] = React.useState([]);
|
| 107 |
const [isLoading, setIsLoading] = React.useState(true);
|
| 108 |
const [nowMs, setNowMs] = React.useState(() => Date.now());
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
React.useEffect(() => {
|
| 111 |
if (profile) {
|
| 112 |
const fetchStats = async () => {
|
|
|
|
| 126 |
console.warn("Retrying dashboard fetch without relation...", error);
|
| 127 |
const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
|
| 128 |
if (basicError) throw basicError;
|
| 129 |
+
setTickets(basicData || []);
|
|
|
|
|
|
|
|
|
|
| 130 |
} else {
|
| 131 |
+
setTickets(data || []);
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
} catch (err) { console.error("Dashboard fetch error:", err); }
|
| 134 |
finally { setIsLoading(false); }
|
| 135 |
};
|
| 136 |
|
| 137 |
fetchStats();
|
| 138 |
+
const interval = setInterval(fetchStats, 30000);
|
| 139 |
+
return () => clearInterval(interval);
|
| 140 |
}
|
| 141 |
+
}, [profile]);
|
| 142 |
|
| 143 |
React.useEffect(() => {
|
| 144 |
const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
|
|
|
|
| 194 |
Dashboard
|
| 195 |
</h1>
|
| 196 |
<p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
|
| 197 |
+
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'inline-block' }}></span>
|
| 198 |
+
Real-time updates active
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
</p>
|
| 200 |
</div>
|
| 201 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: '#F0FDF4', border: '1.5px solid #BBF7D0', borderRadius: '100px' }}>
|
| 202 |
+
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 203 |
+
<span style={{ fontSize: '11px', fontWeight: 700, color: '#15803d', letterSpacing: '0.08em', textTransform: 'uppercase' }}>System Active</span>
|
| 204 |
</div>
|
| 205 |
</div>
|
| 206 |
|
|
|
|
| 277 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
| 278 |
AI Status
|
| 279 |
</h2>
|
| 280 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: '100px', padding: '3px 10px' }}>
|
| 281 |
+
<span style={{ width: 5, height: 5, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 282 |
+
<span style={{ fontSize: '10px', fontWeight: 700, color: '#15803d' }}>LIVE SYNC</span>
|
| 283 |
</div>
|
| 284 |
</div>
|
| 285 |
<div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
|
|
|
|
| 304 |
<div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
|
| 305 |
<p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
|
| 306 |
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
|
| 307 |
+
<Activity size={10} color="#9ca3af" />
|
| 308 |
+
<span style={{ fontSize: '9px', fontWeight: 600, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
| 309 |
+
Last Synced: {formatTimelineDate(new Date())}
|
| 310 |
</span>
|
| 311 |
</div>
|
| 312 |
</div>
|
Frontend/src/admin/pages/AdminSettings.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React
|
| 2 |
import {
|
| 3 |
Settings,
|
| 4 |
Cpu,
|
|
@@ -8,14 +8,6 @@ import {
|
|
| 8 |
ShieldCheck
|
| 9 |
} from 'lucide-react';
|
| 10 |
import useAdminStore from '../store/adminStore';
|
| 11 |
-
import useAuthStore from '../../store/authStore';
|
| 12 |
-
import { supabase } from '../../lib/supabaseClient';
|
| 13 |
-
import {
|
| 14 |
-
DEFAULT_ADMIN_SETTINGS,
|
| 15 |
-
resolveCompanyId,
|
| 16 |
-
settingsFromSystemSettingsRow,
|
| 17 |
-
settingsToSystemSettingsRow
|
| 18 |
-
} from '../../utils/adminSettingsPersistence';
|
| 19 |
import { Card, CardContent } from "../../components/ui/card";
|
| 20 |
import { Select } from "../../components/ui/select";
|
| 21 |
|
|
@@ -25,97 +17,12 @@ import { Select } from "../../components/ui/select";
|
|
| 25 |
*/
|
| 26 |
const AdminSettings = () => {
|
| 27 |
const { settings, updateSettings } = useAdminStore();
|
| 28 |
-
const { user, profile } = useAuthStore();
|
| 29 |
-
const [isLoadingSettings, setIsLoadingSettings] = useState(false);
|
| 30 |
-
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
| 31 |
-
const [statusMessage, setStatusMessage] = useState('');
|
| 32 |
-
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
| 33 |
-
|
| 34 |
-
const companyId = useMemo(() => resolveCompanyId(profile, user), [profile, user]);
|
| 35 |
-
|
| 36 |
-
useEffect(() => {
|
| 37 |
-
let isMounted = true;
|
| 38 |
-
|
| 39 |
-
const loadCompanySettings = async () => {
|
| 40 |
-
if (!companyId) {
|
| 41 |
-
setStatusMessage('Company profile is required before settings can be synced.');
|
| 42 |
-
return;
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
setIsLoadingSettings(true);
|
| 46 |
-
setStatusMessage('');
|
| 47 |
-
|
| 48 |
-
const { data, error } = await supabase
|
| 49 |
-
.from('system_settings')
|
| 50 |
-
.select('ai_confidence_threshold, duplicate_sensitivity, enable_auto_resolve, auto_close_days, email_notifications, admin_alerts')
|
| 51 |
-
.eq('company_id', companyId)
|
| 52 |
-
.maybeSingle();
|
| 53 |
-
|
| 54 |
-
if (!isMounted) return;
|
| 55 |
-
|
| 56 |
-
if (error) {
|
| 57 |
-
setStatusMessage(`Unable to load saved settings: ${error.message}`);
|
| 58 |
-
} else if (data) {
|
| 59 |
-
updateSettings(settingsFromSystemSettingsRow(data, DEFAULT_ADMIN_SETTINGS));
|
| 60 |
-
setHasUnsavedChanges(false);
|
| 61 |
-
setStatusMessage('Saved company settings loaded.');
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
setIsLoadingSettings(false);
|
| 65 |
-
};
|
| 66 |
-
|
| 67 |
-
loadCompanySettings();
|
| 68 |
-
|
| 69 |
-
return () => {
|
| 70 |
-
isMounted = false;
|
| 71 |
-
};
|
| 72 |
-
}, [companyId, updateSettings]);
|
| 73 |
-
|
| 74 |
-
const saveCompanySettings = useCallback(async (nextSettings, { silent = false } = {}) => {
|
| 75 |
-
if (!companyId) {
|
| 76 |
-
setStatusMessage('Company profile is required before settings can be saved.');
|
| 77 |
-
return;
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
setIsSavingSettings(true);
|
| 81 |
-
if (!silent) {
|
| 82 |
-
setStatusMessage('');
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
const { error } = await supabase
|
| 86 |
-
.from('system_settings')
|
| 87 |
-
.upsert(settingsToSystemSettingsRow(nextSettings, companyId), { onConflict: 'company_id' });
|
| 88 |
-
|
| 89 |
-
if (error) {
|
| 90 |
-
setStatusMessage(`Unable to save settings: ${error.message}`);
|
| 91 |
-
} else {
|
| 92 |
-
setHasUnsavedChanges(false);
|
| 93 |
-
setStatusMessage('Settings saved for this company.');
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
setIsSavingSettings(false);
|
| 97 |
-
}, [companyId]);
|
| 98 |
|
|
|
|
| 99 |
const handleChange = (key, value) => {
|
| 100 |
updateSettings({ [key]: value });
|
| 101 |
-
setHasUnsavedChanges(true);
|
| 102 |
-
setStatusMessage('Saving changes...');
|
| 103 |
};
|
| 104 |
|
| 105 |
-
const handleSaveSettings = useCallback(() => {
|
| 106 |
-
saveCompanySettings(settings);
|
| 107 |
-
}, [saveCompanySettings, settings]);
|
| 108 |
-
|
| 109 |
-
useEffect(() => {
|
| 110 |
-
if (!hasUnsavedChanges || isLoadingSettings || isSavingSettings) return undefined;
|
| 111 |
-
|
| 112 |
-
const saveTimer = window.setTimeout(() => {
|
| 113 |
-
saveCompanySettings(settings, { silent: true });
|
| 114 |
-
}, 800);
|
| 115 |
-
|
| 116 |
-
return () => window.clearTimeout(saveTimer);
|
| 117 |
-
}, [hasUnsavedChanges, isLoadingSettings, isSavingSettings, saveCompanySettings, settings]);
|
| 118 |
-
|
| 119 |
return (
|
| 120 |
<div className="max-w-4xl mx-auto py-6 space-y-10 pb-20 animate-in fade-in duration-700">
|
| 121 |
{/* 1. Header Area */}
|
|
@@ -128,22 +35,6 @@ const AdminSettings = () => {
|
|
| 128 |
<ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
|
| 129 |
</p>
|
| 130 |
</div>
|
| 131 |
-
<div className="flex flex-col items-start md:items-end gap-2">
|
| 132 |
-
<button
|
| 133 |
-
type="button"
|
| 134 |
-
onClick={handleSaveSettings}
|
| 135 |
-
disabled={!companyId || isLoadingSettings || isSavingSettings || !hasUnsavedChanges}
|
| 136 |
-
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-xs font-black uppercase tracking-widest text-white shadow-xl shadow-slate-200 transition-all hover:bg-indigo-600 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
|
| 137 |
-
>
|
| 138 |
-
<Save size={16} />
|
| 139 |
-
{isSavingSettings ? 'Saving...' : 'Save Now'}
|
| 140 |
-
</button>
|
| 141 |
-
{statusMessage && (
|
| 142 |
-
<p className="max-w-xs text-left md:text-right text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
| 143 |
-
{statusMessage}
|
| 144 |
-
</p>
|
| 145 |
-
)}
|
| 146 |
-
</div>
|
| 147 |
</div>
|
| 148 |
|
| 149 |
<div className="space-y-8">
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
import {
|
| 3 |
Settings,
|
| 4 |
Cpu,
|
|
|
|
| 8 |
ShieldCheck
|
| 9 |
} from 'lucide-react';
|
| 10 |
import useAdminStore from '../store/adminStore';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import { Card, CardContent } from "../../components/ui/card";
|
| 12 |
import { Select } from "../../components/ui/select";
|
| 13 |
|
|
|
|
| 17 |
*/
|
| 18 |
const AdminSettings = () => {
|
| 19 |
const { settings, updateSettings } = useAdminStore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
// Handlers
|
| 22 |
const handleChange = (key, value) => {
|
| 23 |
updateSettings({ [key]: value });
|
|
|
|
|
|
|
| 24 |
};
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return (
|
| 27 |
<div className="max-w-4xl mx-auto py-6 space-y-10 pb-20 animate-in fade-in duration-700">
|
| 28 |
{/* 1. Header Area */}
|
|
|
|
| 35 |
<ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
|
| 36 |
</p>
|
| 37 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
|
| 40 |
<div className="space-y-8">
|
Frontend/src/admin/pages/AdminTicketDetail.jsx
CHANGED
|
@@ -16,7 +16,6 @@ import { formatTicketId } from "../../utils/format";
|
|
| 16 |
import SLABadge from "../components/SLABadge";
|
| 17 |
import { formatFullTimestamp } from "../../utils/dateUtils";
|
| 18 |
import TicketTimeline from "../../user/components/TicketTimeline";
|
| 19 |
-
import TicketAuditTimeline from "../components/TicketAuditTimeline";
|
| 20 |
|
| 21 |
const AdminTicketDetail = () => {
|
| 22 |
const { ticket_id } = useParams();
|
|
@@ -35,7 +34,6 @@ const AdminTicketDetail = () => {
|
|
| 35 |
const [imageUrl, setImageUrl] = useState(null);
|
| 36 |
const [isUpdating, setIsUpdating] = useState(null);
|
| 37 |
const [isLive, setIsLive] = useState(false);
|
| 38 |
-
const [showOriginalText, setShowOriginalText] = useState(false);
|
| 39 |
|
| 40 |
const [correctionForm, setCorrectionForm] = useState({
|
| 41 |
category: '',
|
|
@@ -199,11 +197,6 @@ const AdminTicketDetail = () => {
|
|
| 199 |
const displayPriority = ticket.priority || 'Medium';
|
| 200 |
const displaySummary = ticket.summary || ticket.subject || 'No Summary';
|
| 201 |
const displayText = ticket.description || ticket.text || displaySummary;
|
| 202 |
-
const translationMeta = ticket.metadata?.translation;
|
| 203 |
-
const originalTextMeta = ticket.metadata?.original_text;
|
| 204 |
-
const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
|
| 205 |
-
const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
|
| 206 |
-
const renderedText = showOriginalText && isTranslated ? originalTextMeta.description : displayText;
|
| 207 |
|
| 208 |
return (
|
| 209 |
<div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
|
|
@@ -380,22 +373,8 @@ const AdminTicketDetail = () => {
|
|
| 380 |
<span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
|
| 381 |
</div>
|
| 382 |
<div style={{ padding: '28px' }}>
|
| 383 |
-
{isTranslated && (
|
| 384 |
-
<div style={{ marginBottom: '16px', border: '1px solid #bae6fd', background: '#f0f9ff', borderRadius: '12px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
| 385 |
-
<span style={{ fontSize: '12px', fontWeight: 600, color: '#0c4a6e' }}>
|
| 386 |
-
Translated from {sourceLanguageName}
|
| 387 |
-
</span>
|
| 388 |
-
<button
|
| 389 |
-
type="button"
|
| 390 |
-
onClick={() => setShowOriginalText(prev => !prev)}
|
| 391 |
-
style={{ fontSize: '11px', fontWeight: 700, color: '#0369a1', background: 'transparent', border: 'none', cursor: 'pointer' }}
|
| 392 |
-
>
|
| 393 |
-
{showOriginalText ? 'View English' : 'View Original'}
|
| 394 |
-
</button>
|
| 395 |
-
</div>
|
| 396 |
-
)}
|
| 397 |
<div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
|
| 398 |
-
"{
|
| 399 |
</div>
|
| 400 |
|
| 401 |
{imageUrl && (
|
|
@@ -424,8 +403,6 @@ const AdminTicketDetail = () => {
|
|
| 424 |
</div>
|
| 425 |
<TicketTimeline ticket={ticket} />
|
| 426 |
</div>
|
| 427 |
-
|
| 428 |
-
<TicketAuditTimeline ticketId={ticket.id} companyId={ticket.company_id} />
|
| 429 |
</div>
|
| 430 |
|
| 431 |
{/* AI Column */}
|
|
|
|
| 16 |
import SLABadge from "../components/SLABadge";
|
| 17 |
import { formatFullTimestamp } from "../../utils/dateUtils";
|
| 18 |
import TicketTimeline from "../../user/components/TicketTimeline";
|
|
|
|
| 19 |
|
| 20 |
const AdminTicketDetail = () => {
|
| 21 |
const { ticket_id } = useParams();
|
|
|
|
| 34 |
const [imageUrl, setImageUrl] = useState(null);
|
| 35 |
const [isUpdating, setIsUpdating] = useState(null);
|
| 36 |
const [isLive, setIsLive] = useState(false);
|
|
|
|
| 37 |
|
| 38 |
const [correctionForm, setCorrectionForm] = useState({
|
| 39 |
category: '',
|
|
|
|
| 197 |
const displayPriority = ticket.priority || 'Medium';
|
| 198 |
const displaySummary = ticket.summary || ticket.subject || 'No Summary';
|
| 199 |
const displayText = ticket.description || ticket.text || displaySummary;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
return (
|
| 202 |
<div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
|
|
|
|
| 373 |
<span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
|
| 374 |
</div>
|
| 375 |
<div style={{ padding: '28px' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
<div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
|
| 377 |
+
"{displayText}"
|
| 378 |
</div>
|
| 379 |
|
| 380 |
{imageUrl && (
|
|
|
|
| 403 |
</div>
|
| 404 |
<TicketTimeline ticket={ticket} />
|
| 405 |
</div>
|
|
|
|
|
|
|
| 406 |
</div>
|
| 407 |
|
| 408 |
{/* AI Column */}
|
Frontend/src/admin/pages/AdminTickets.jsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
-
import React, {
|
| 2 |
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
import useAuthStore from "../../store/authStore";
|
| 4 |
import useToastStore from "../../store/toastStore";
|
| 5 |
import { supabase } from "../../lib/supabaseClient";
|
| 6 |
-
import useTicketsRealtime from "../../hooks/useTicketsRealtime";
|
| 7 |
import {
|
| 8 |
Search,
|
| 9 |
Filter,
|
|
@@ -30,7 +29,7 @@ import { formatTimelineDate } from "../../utils/dateUtils";
|
|
| 30 |
const AdminTickets = () => {
|
| 31 |
const navigate = useNavigate();
|
| 32 |
const location = useLocation();
|
| 33 |
-
const { user
|
| 34 |
const { showToast } = useToastStore();
|
| 35 |
|
| 36 |
// Data State
|
|
@@ -47,27 +46,6 @@ const AdminTickets = () => {
|
|
| 47 |
const [teamFilter, setTeamFilter] = useState('All');
|
| 48 |
const [agents, setAgents] = useState([]); // All staff/admins in the company
|
| 49 |
|
| 50 |
-
const ticketMatchesFilters = useCallback((ticket) => {
|
| 51 |
-
if (statusFilter !== 'All' && String(ticket.status || '').toLowerCase() !== statusFilter.toLowerCase()) return false;
|
| 52 |
-
if (categoryFilter !== 'All' && ticket.category !== categoryFilter) return false;
|
| 53 |
-
if (priorityFilter !== 'All' && String(ticket.priority || '').toLowerCase() !== priorityFilter.toLowerCase()) return false;
|
| 54 |
-
if (teamFilter !== 'All' && ticket.assigned_team !== teamFilter) return false;
|
| 55 |
-
return true;
|
| 56 |
-
}, [categoryFilter, priorityFilter, statusFilter, teamFilter]);
|
| 57 |
-
|
| 58 |
-
const handleRealtimeInsert = useCallback((ticket) => {
|
| 59 |
-
showToast(`New Incident Reported: #${formatTicketId(ticket.id)}`, "success");
|
| 60 |
-
}, [showToast]);
|
| 61 |
-
|
| 62 |
-
const { lastChangedTicketId } = useTicketsRealtime({
|
| 63 |
-
company: profile?.company,
|
| 64 |
-
enabled: Boolean(profile),
|
| 65 |
-
onTicketsChange: setTickets,
|
| 66 |
-
onInsert: handleRealtimeInsert,
|
| 67 |
-
shouldInclude: ticketMatchesFilters,
|
| 68 |
-
channelName: 'admin_tickets_realtime',
|
| 69 |
-
});
|
| 70 |
-
|
| 71 |
const fetchInitialData = async () => {
|
| 72 |
setLoading(true);
|
| 73 |
try {
|
|
@@ -135,6 +113,38 @@ const AdminTickets = () => {
|
|
| 135 |
|
| 136 |
useEffect(() => {
|
| 137 |
fetchInitialData();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}, [statusFilter, categoryFilter, priorityFilter, teamFilter]);
|
| 139 |
|
| 140 |
// Seed search from URL
|
|
@@ -295,11 +305,8 @@ const AdminTickets = () => {
|
|
| 295 |
</tr>
|
| 296 |
</thead>
|
| 297 |
<tbody className="divide-y divide-slate-50">
|
| 298 |
-
{filteredTickets.map((ticket) =>
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
return (
|
| 302 |
-
<tr key={ticket.id} className={`hover:bg-slate-50/50 transition-colors group ${wasLiveChanged ? 'bg-emerald-50/70 ring-1 ring-emerald-100' : ''} ${isUpdating === ticket.id ? 'opacity-50 pointer-events-none' : ''}`}>
|
| 303 |
{/* Ticket ID */}
|
| 304 |
<td className="px-6 py-6">
|
| 305 |
<span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
|
|
@@ -352,11 +359,6 @@ const AdminTickets = () => {
|
|
| 352 |
{ticket.category}
|
| 353 |
<span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
|
| 354 |
</span>
|
| 355 |
-
{ticket?.metadata?.translation?.translated && (
|
| 356 |
-
<span className="text-[10px] text-sky-700 mt-1">
|
| 357 |
-
Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
|
| 358 |
-
</span>
|
| 359 |
-
)}
|
| 360 |
</div>
|
| 361 |
</td>
|
| 362 |
|
|
@@ -472,8 +474,7 @@ const AdminTickets = () => {
|
|
| 472 |
</div>
|
| 473 |
</td>
|
| 474 |
</tr>
|
| 475 |
-
|
| 476 |
-
})}
|
| 477 |
</tbody>
|
| 478 |
</table>
|
| 479 |
</div>
|
|
|
|
| 1 |
+
import React, { useState, useMemo, useEffect } from 'react';
|
| 2 |
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
import useAuthStore from "../../store/authStore";
|
| 4 |
import useToastStore from "../../store/toastStore";
|
| 5 |
import { supabase } from "../../lib/supabaseClient";
|
|
|
|
| 6 |
import {
|
| 7 |
Search,
|
| 8 |
Filter,
|
|
|
|
| 29 |
const AdminTickets = () => {
|
| 30 |
const navigate = useNavigate();
|
| 31 |
const location = useLocation();
|
| 32 |
+
const { user } = useAuthStore();
|
| 33 |
const { showToast } = useToastStore();
|
| 34 |
|
| 35 |
// Data State
|
|
|
|
| 46 |
const [teamFilter, setTeamFilter] = useState('All');
|
| 47 |
const [agents, setAgents] = useState([]); // All staff/admins in the company
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
const fetchInitialData = async () => {
|
| 50 |
setLoading(true);
|
| 51 |
try {
|
|
|
|
| 113 |
|
| 114 |
useEffect(() => {
|
| 115 |
fetchInitialData();
|
| 116 |
+
|
| 117 |
+
// 4. Real-time subscription to ticket changes
|
| 118 |
+
const { profile } = useAuthStore.getState();
|
| 119 |
+
const channel = supabase
|
| 120 |
+
.channel('admin_tickets_realtime')
|
| 121 |
+
.on(
|
| 122 |
+
'postgres_changes',
|
| 123 |
+
{
|
| 124 |
+
event: '*',
|
| 125 |
+
schema: 'public',
|
| 126 |
+
table: 'tickets',
|
| 127 |
+
filter: profile?.company ? `company=eq.${profile.company}` : undefined
|
| 128 |
+
},
|
| 129 |
+
(payload) => {
|
| 130 |
+
console.log("Admin tickets sync event:", payload.eventType, payload.new);
|
| 131 |
+
if (payload.eventType === 'INSERT') {
|
| 132 |
+
setTickets(prev => [payload.new, ...prev]);
|
| 133 |
+
showToast(`New Incident Reported: #${payload.new.id}`, "success");
|
| 134 |
+
// Play a subtle sound or visual cue if needed
|
| 135 |
+
} else if (payload.eventType === 'UPDATE') {
|
| 136 |
+
setTickets(prev => prev.map(t => t.id === payload.new.id ? { ...t, ...payload.new } : t));
|
| 137 |
+
} else if (payload.eventType === 'DELETE') {
|
| 138 |
+
setTickets(prev => prev.filter(t => t.id === payload.old.id));
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
.subscribe();
|
| 143 |
+
|
| 144 |
+
return () => {
|
| 145 |
+
supabase.removeChannel(channel);
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
}, [statusFilter, categoryFilter, priorityFilter, teamFilter]);
|
| 149 |
|
| 150 |
// Seed search from URL
|
|
|
|
| 305 |
</tr>
|
| 306 |
</thead>
|
| 307 |
<tbody className="divide-y divide-slate-50">
|
| 308 |
+
{filteredTickets.map((ticket) => (
|
| 309 |
+
<tr key={ticket.id} className={`hover:bg-slate-50/50 transition-colors group ${isUpdating === ticket.id ? 'opacity-50 pointer-events-none' : ''}`}>
|
|
|
|
|
|
|
|
|
|
| 310 |
{/* Ticket ID */}
|
| 311 |
<td className="px-6 py-6">
|
| 312 |
<span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
|
|
|
|
| 359 |
{ticket.category}
|
| 360 |
<span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
|
| 361 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
</div>
|
| 363 |
</td>
|
| 364 |
|
|
|
|
| 474 |
</div>
|
| 475 |
</td>
|
| 476 |
</tr>
|
| 477 |
+
))}
|
|
|
|
| 478 |
</tbody>
|
| 479 |
</table>
|
| 480 |
</div>
|
Frontend/src/components/shared/BugReportWidget.jsx
CHANGED
|
@@ -23,6 +23,7 @@ function useDiagnostics() {
|
|
| 23 |
const browserInfo = navigator.userAgent;
|
| 24 |
const screenInfo = `${window.innerWidth}x${window.innerHeight}`;
|
| 25 |
|
|
|
|
| 26 |
setDiagnostics(prev => ({
|
| 27 |
...prev,
|
| 28 |
url: window.location.href,
|
|
@@ -30,6 +31,16 @@ function useDiagnostics() {
|
|
| 30 |
screen: screenInfo
|
| 31 |
}));
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
// Global Error Listener
|
| 34 |
const handleError = (e) => {
|
| 35 |
setDiagnostics(prev => ({
|
|
@@ -40,6 +51,7 @@ function useDiagnostics() {
|
|
| 40 |
window.addEventListener('error', handleError);
|
| 41 |
|
| 42 |
return () => {
|
|
|
|
| 43 |
window.removeEventListener('error', handleError);
|
| 44 |
};
|
| 45 |
}, []);
|
|
|
|
| 23 |
const browserInfo = navigator.userAgent;
|
| 24 |
const screenInfo = `${window.innerWidth}x${window.innerHeight}`;
|
| 25 |
|
| 26 |
+
|
| 27 |
setDiagnostics(prev => ({
|
| 28 |
...prev,
|
| 29 |
url: window.location.href,
|
|
|
|
| 31 |
screen: screenInfo
|
| 32 |
}));
|
| 33 |
|
| 34 |
+
// Intercept console.error
|
| 35 |
+
const originalConsoleError = console.error;
|
| 36 |
+
console.error = function (...args) {
|
| 37 |
+
setDiagnostics(prev => ({
|
| 38 |
+
...prev,
|
| 39 |
+
consoleErrors: [...prev.consoleErrors, args.join(' ')].slice(-10) // keep last 10
|
| 40 |
+
}));
|
| 41 |
+
originalConsoleError.apply(console, args);
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
// Global Error Listener
|
| 45 |
const handleError = (e) => {
|
| 46 |
setDiagnostics(prev => ({
|
|
|
|
| 51 |
window.addEventListener('error', handleError);
|
| 52 |
|
| 53 |
return () => {
|
| 54 |
+
console.error = originalConsoleError;
|
| 55 |
window.removeEventListener('error', handleError);
|
| 56 |
};
|
| 57 |
}, []);
|
Frontend/src/config.js
CHANGED
|
@@ -13,6 +13,5 @@ const getBackendUrl = () => {
|
|
| 13 |
export const API_CONFIG = {
|
| 14 |
BACKEND_URL: getBackendUrl(),
|
| 15 |
FRONTEND_URL: window.location.origin,
|
| 16 |
-
IS_PROD: import.meta.env.PROD
|
| 17 |
-
USE_MOCK: import.meta.env.VITE_USE_MOCK !== 'false' // default true
|
| 18 |
};
|
|
|
|
| 13 |
export const API_CONFIG = {
|
| 14 |
BACKEND_URL: getBackendUrl(),
|
| 15 |
FRONTEND_URL: window.location.origin,
|
| 16 |
+
IS_PROD: import.meta.env.PROD
|
|
|
|
| 17 |
};
|
Frontend/src/legacy_ui/Dashboard.jsx
CHANGED
|
@@ -63,7 +63,7 @@ const Dashboard = () => {
|
|
| 63 |
}
|
| 64 |
};
|
| 65 |
fetchTickets();
|
| 66 |
-
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
// Detect newly inserted or updated tickets for highlight animation
|
|
@@ -84,7 +84,7 @@ const Dashboard = () => {
|
|
| 84 |
});
|
| 85 |
|
| 86 |
prevTicketsRef.current = tickets;
|
| 87 |
-
|
| 88 |
}, [tickets]);
|
| 89 |
|
| 90 |
// Summary Counts
|
|
|
|
| 63 |
}
|
| 64 |
};
|
| 65 |
fetchTickets();
|
| 66 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
// Detect newly inserted or updated tickets for highlight animation
|
|
|
|
| 84 |
});
|
| 85 |
|
| 86 |
prevTicketsRef.current = tickets;
|
| 87 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 88 |
}, [tickets]);
|
| 89 |
|
| 90 |
// Summary Counts
|
Frontend/src/pages/AdminSignup.jsx
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
| 10 |
} from "lucide-react";
|
| 11 |
import useAuthStore from "../store/authStore";
|
| 12 |
import { Select } from "../components/ui/select";
|
| 13 |
-
import { getPasswordValidation, getPasswordValidationMessage } from "../utils/passwordValidation";
|
| 14 |
|
| 15 |
/**
|
| 16 |
* AdminSignup — Premium Multi-step Company Registration
|
|
@@ -45,14 +44,6 @@ function AdminSignup() {
|
|
| 45 |
|
| 46 |
const navigate = useNavigate();
|
| 47 |
const { signup, loading, user, profile } = useAuthStore();
|
| 48 |
-
const passwordRules = {
|
| 49 |
-
minLength: 8,
|
| 50 |
-
requireUppercase: true,
|
| 51 |
-
requireNumber: true,
|
| 52 |
-
requireSpecial: true,
|
| 53 |
-
};
|
| 54 |
-
const passwordChecks = getPasswordValidation(formData.password, passwordRules);
|
| 55 |
-
const passwordWarning = getPasswordValidationMessage(passwordChecks, passwordRules);
|
| 56 |
|
| 57 |
// Redirect if already logged in and verified
|
| 58 |
useEffect(() => {
|
|
@@ -89,8 +80,8 @@ function AdminSignup() {
|
|
| 89 |
setError("Please fill in all required personal information.");
|
| 90 |
return;
|
| 91 |
}
|
| 92 |
-
if (
|
| 93 |
-
setError(
|
| 94 |
return;
|
| 95 |
}
|
| 96 |
if (formData.password !== formData.confirmPassword) {
|
|
@@ -431,7 +422,7 @@ function AdminSignup() {
|
|
| 431 |
</div>
|
| 432 |
{/* Strength Meter */}
|
| 433 |
{formData.password && (
|
| 434 |
-
<div className="mt-2 space-y-
|
| 435 |
<div className="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
| 436 |
<span>Strength: {getStrengthText()}</span>
|
| 437 |
<span>{passwordStrength}%</span>
|
|
@@ -443,12 +434,6 @@ function AdminSignup() {
|
|
| 443 |
animate={{ width: `${passwordStrength}%` }}
|
| 444 |
/>
|
| 445 |
</div>
|
| 446 |
-
<div
|
| 447 |
-
aria-live="polite"
|
| 448 |
-
className={`text-[11px] font-semibold ${passwordWarning ? "text-red-600" : "text-emerald-700"}`}
|
| 449 |
-
>
|
| 450 |
-
{passwordWarning || "Password requirements met."}
|
| 451 |
-
</div>
|
| 452 |
</div>
|
| 453 |
)}
|
| 454 |
</div>
|
|
|
|
| 10 |
} from "lucide-react";
|
| 11 |
import useAuthStore from "../store/authStore";
|
| 12 |
import { Select } from "../components/ui/select";
|
|
|
|
| 13 |
|
| 14 |
/**
|
| 15 |
* AdminSignup — Premium Multi-step Company Registration
|
|
|
|
| 44 |
|
| 45 |
const navigate = useNavigate();
|
| 46 |
const { signup, loading, user, profile } = useAuthStore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
// Redirect if already logged in and verified
|
| 49 |
useEffect(() => {
|
|
|
|
| 80 |
setError("Please fill in all required personal information.");
|
| 81 |
return;
|
| 82 |
}
|
| 83 |
+
if (formData.password.length < 8) {
|
| 84 |
+
setError("Password must be at least 8 characters long.");
|
| 85 |
return;
|
| 86 |
}
|
| 87 |
if (formData.password !== formData.confirmPassword) {
|
|
|
|
| 422 |
</div>
|
| 423 |
{/* Strength Meter */}
|
| 424 |
{formData.password && (
|
| 425 |
+
<div className="mt-2 space-y-1">
|
| 426 |
<div className="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
| 427 |
<span>Strength: {getStrengthText()}</span>
|
| 428 |
<span>{passwordStrength}%</span>
|
|
|
|
| 434 |
animate={{ width: `${passwordStrength}%` }}
|
| 435 |
/>
|
| 436 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
</div>
|
| 438 |
)}
|
| 439 |
</div>
|
Frontend/src/pages/LandingPage.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useRef, useEffect, useState
|
| 2 |
|
| 3 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
import { useNavigate } from 'react-router-dom';
|
|
@@ -14,12 +14,6 @@ import {
|
|
| 14 |
import useAuthStore from '../store/authStore';
|
| 15 |
import TeamSection from '../components/landing/TeamSection';
|
| 16 |
|
| 17 |
-
const CTA_TRANSITION = 'transition-all duration-200 ease-out transform-gpu will-change-transform';
|
| 18 |
-
const CTA_SCALE = 'hover:scale-105 active:scale-[0.98]';
|
| 19 |
-
const CTA_PRIMARY_GLOW = 'shadow-lg shadow-emerald-900/20 hover:shadow-emerald-900/35';
|
| 20 |
-
const CTA_ICON_SHIFT = 'transition-transform duration-200 group-hover:translate-x-1';
|
| 21 |
-
const CTA_PLAY_SHIFT = 'transition-transform duration-200 group-hover:translate-x-0.5';
|
| 22 |
-
|
| 23 |
// ---- Count-up animation component ----
|
| 24 |
function AnimatedStat({ target, suffix = '', prefix = '', label, isWord = false }) {
|
| 25 |
const [display, setDisplay] = useState(isWord ? target : '0');
|
|
@@ -130,9 +124,9 @@ function DemoModal({ onClose }) {
|
|
| 130 |
<div className="flex gap-3 w-full md:w-auto">
|
| 131 |
<button
|
| 132 |
onClick={() => { onClose(); window.location.href = '/admin-signup'; }}
|
| 133 |
-
className=
|
| 134 |
>
|
| 135 |
-
Start Free <ArrowRight className=
|
| 136 |
</button>
|
| 137 |
</div>
|
| 138 |
</div>
|
|
@@ -151,7 +145,7 @@ export default function LandingPage() {
|
|
| 151 |
const [activeStep, setActiveStep] = useState(0);
|
| 152 |
const [isRedirecting, setIsRedirecting] = useState(false);
|
| 153 |
|
| 154 |
-
const steps =
|
| 155 |
{
|
| 156 |
num: '01',
|
| 157 |
title: 'Messy User Input',
|
|
@@ -214,7 +208,7 @@ export default function LandingPage() {
|
|
| 214 |
</div>
|
| 215 |
)
|
| 216 |
}
|
| 217 |
-
]
|
| 218 |
|
| 219 |
useEffect(() => {
|
| 220 |
if (!loading && user && profile) {
|
|
@@ -231,7 +225,7 @@ export default function LandingPage() {
|
|
| 231 |
period: '/mo',
|
| 232 |
desc: 'Perfect for small teams exploring AI helpdesk.',
|
| 233 |
cta: 'Get Started Free',
|
| 234 |
-
ctaStyle:
|
| 235 |
features: ['Up to 50 tickets/mo', 'Basic AI Categorization', 'Email Support', '1 Team Member', 'Public API Access'],
|
| 236 |
popular: false,
|
| 237 |
},
|
|
@@ -241,7 +235,7 @@ export default function LandingPage() {
|
|
| 241 |
period: '/mo',
|
| 242 |
desc: 'For growing IT teams needing full automation.',
|
| 243 |
cta: 'Start Free Trial',
|
| 244 |
-
ctaStyle:
|
| 245 |
features: ['Up to 500 tickets/mo', 'Advanced AI Parsing', 'Priority Detection Engine', 'Duplicate Detection', '5 Team Members', 'Priority Email Support'],
|
| 246 |
popular: true,
|
| 247 |
},
|
|
@@ -251,7 +245,7 @@ export default function LandingPage() {
|
|
| 251 |
period: '',
|
| 252 |
desc: 'For large organizations with complex IT landscapes.',
|
| 253 |
cta: 'Contact Sales',
|
| 254 |
-
ctaStyle:
|
| 255 |
features: ['Unlimited tickets', 'Custom AI Fine-Tuning', 'SSO & Audit Logs', 'Dedicated SLA Manager', 'Unlimited Members', 'VAPT & Compliance Reports'],
|
| 256 |
popular: false,
|
| 257 |
},
|
|
@@ -322,13 +316,13 @@ export default function LandingPage() {
|
|
| 322 |
</button>
|
| 323 |
<button
|
| 324 |
onClick={() => setShowDemo(true)}
|
| 325 |
-
className=
|
| 326 |
>
|
| 327 |
-
<Play className=
|
| 328 |
</button>
|
| 329 |
<button
|
| 330 |
onClick={() => navigate('/admin-signup')}
|
| 331 |
-
className=
|
| 332 |
>
|
| 333 |
Get Started Free
|
| 334 |
</button>
|
|
@@ -351,13 +345,13 @@ export default function LandingPage() {
|
|
| 351 |
<a href="#how-it-works" onClick={() => setIsMenuOpen(false)} className="block text-base font-semibold text-gray-700 hover:text-emerald-800 py-2">How It Works</a>
|
| 352 |
<a href="#pricing" onClick={() => setIsMenuOpen(false)} className="block text-base font-semibold text-gray-700 hover:text-emerald-800 py-2">Pricing</a>
|
| 353 |
<div className="pt-4 flex flex-col gap-3 border-t border-gray-100">
|
| 354 |
-
<button onClick={() => { setIsMenuOpen(false); setShowDemo(true); }} className=
|
| 355 |
-
<Play className=
|
| 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=
|
| 361 |
Get Started Free
|
| 362 |
</button>
|
| 363 |
</div>
|
|
@@ -388,15 +382,15 @@ export default function LandingPage() {
|
|
| 388 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6">
|
| 389 |
<button
|
| 390 |
onClick={() => navigate('/admin-signup')}
|
| 391 |
-
className=
|
| 392 |
>
|
| 393 |
-
Get Started Free <ArrowRight className=
|
| 394 |
</button>
|
| 395 |
<button
|
| 396 |
onClick={() => setShowDemo(true)}
|
| 397 |
-
className=
|
| 398 |
>
|
| 399 |
-
<Play className=
|
| 400 |
</button>
|
| 401 |
</div>
|
| 402 |
|
|
@@ -433,7 +427,7 @@ export default function LandingPage() {
|
|
| 433 |
<div className="mb-4">
|
| 434 |
<h3 className="text-sm font-bold text-gray-800 mb-1">Subject: Wifi down again in Lab 3??</h3>
|
| 435 |
<p className="text-sm text-gray-600 leading-relaxed">
|
| 436 |
-
Hey support, the wifi in <span className="bg-yellow-
|
| 437 |
Can't connect at all. Class starts in 20 mins, need this fixed ASAP!<br /><br />
|
| 438 |
Thanks,<br />Sarah
|
| 439 |
</p>
|
|
@@ -664,7 +658,7 @@ export default function LandingPage() {
|
|
| 664 |
<div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 text-emerald-400 rounded-full text-[10px] md:text-xs font-bold uppercase tracking-widest border border-emerald-500/20 mb-4 md:mb-6">
|
| 665 |
The Journey
|
| 666 |
</div>
|
| 667 |
-
<h2 className="text-3xl sm:text-4xl md:text-6xl font-
|
| 668 |
From Chaos <br />
|
| 669 |
to <span className="text-emerald-500">Clarity.</span>
|
| 670 |
</h2>
|
|
@@ -675,25 +669,28 @@ export default function LandingPage() {
|
|
| 675 |
key={idx}
|
| 676 |
onMouseEnter={() => setActiveStep(idx)}
|
| 677 |
onClick={() => setActiveStep(idx)}
|
| 678 |
-
className={`group cursor-pointer p-6 rounded-3xl transition-all duration-
|
| 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-
|
| 685 |
{step.num}
|
| 686 |
</div>
|
| 687 |
<div>
|
| 688 |
-
<h3 className={`text-xl font-
|
| 689 |
{step.title}
|
| 690 |
</h3>
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
|
|
|
|
|
|
|
|
|
| 697 |
</div>
|
| 698 |
</div>
|
| 699 |
</div>
|
|
@@ -704,14 +701,14 @@ export default function LandingPage() {
|
|
| 704 |
{/* Right: Visual Display */}
|
| 705 |
<div className="w-full md:w-1/2 h-[350px] md:h-[500px] relative">
|
| 706 |
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-[32px] md:rounded-[40px] border border-white/5 backdrop-blur-3xl overflow-hidden p-6 md:p-12 flex items-center justify-center">
|
| 707 |
-
<AnimatePresence mode="
|
| 708 |
<motion.div
|
| 709 |
key={activeStep}
|
| 710 |
-
initial={{ opacity: 0, scale: 0.
|
| 711 |
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 712 |
-
exit={{ opacity: 0, scale: 1.
|
| 713 |
-
transition={{
|
| 714 |
-
className="w-full h-full flex flex-col items-center justify-center
|
| 715 |
>
|
| 716 |
<div className="mb-6 inline-flex items-center gap-2 px-4 py-1.5 bg-white/5 text-white/50 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
|
| 717 |
<div className={`w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse`} />
|
|
@@ -775,7 +772,7 @@ export default function LandingPage() {
|
|
| 775 |
<button
|
| 776 |
onClick={() => handlePricingClick(name)}
|
| 777 |
disabled={isRedirecting && name === 'Growth'}
|
| 778 |
-
className={`w-full flex items-center justify-center gap-2 py-3 rounded-xl font-semibold mb-8 text-sm ${ctaStyle} ${isRedirecting && name === 'Growth' ? 'opacity-80 cursor-not-allowed' : ''}`}
|
| 779 |
>
|
| 780 |
{isRedirecting && name === 'Growth' ? (
|
| 781 |
<>
|
|
@@ -818,15 +815,15 @@ export default function LandingPage() {
|
|
| 818 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
| 819 |
<button
|
| 820 |
onClick={() => navigate('/admin-signup')}
|
| 821 |
-
className=
|
| 822 |
>
|
| 823 |
Get Started Free
|
| 824 |
</button>
|
| 825 |
<button
|
| 826 |
onClick={() => setShowDemo(true)}
|
| 827 |
-
className=
|
| 828 |
>
|
| 829 |
-
<Play className=
|
| 830 |
</button>
|
| 831 |
</div>
|
| 832 |
<div className="mt-8">
|
|
|
|
| 1 |
+
import React, { useRef, useEffect, useState } from 'react';
|
| 2 |
|
| 3 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
import { useNavigate } from 'react-router-dom';
|
|
|
|
| 14 |
import useAuthStore from '../store/authStore';
|
| 15 |
import TeamSection from '../components/landing/TeamSection';
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
// ---- Count-up animation component ----
|
| 18 |
function AnimatedStat({ target, suffix = '', prefix = '', label, isWord = false }) {
|
| 19 |
const [display, setDisplay] = useState(isWord ? target : '0');
|
|
|
|
| 124 |
<div className="flex gap-3 w-full md:w-auto">
|
| 125 |
<button
|
| 126 |
onClick={() => { onClose(); window.location.href = '/admin-signup'; }}
|
| 127 |
+
className="flex-1 md:px-8 bg-emerald-600 hover:bg-emerald-500 text-white py-3 rounded-xl font-black italic uppercase tracking-wider transition-all flex items-center justify-center gap-2 shadow-lg shadow-emerald-500/20"
|
| 128 |
>
|
| 129 |
+
Start Free <ArrowRight className="w-4 h-4 ml-1" />
|
| 130 |
</button>
|
| 131 |
</div>
|
| 132 |
</div>
|
|
|
|
| 145 |
const [activeStep, setActiveStep] = useState(0);
|
| 146 |
const [isRedirecting, setIsRedirecting] = useState(false);
|
| 147 |
|
| 148 |
+
const steps = [
|
| 149 |
{
|
| 150 |
num: '01',
|
| 151 |
title: 'Messy User Input',
|
|
|
|
| 208 |
</div>
|
| 209 |
)
|
| 210 |
}
|
| 211 |
+
];
|
| 212 |
|
| 213 |
useEffect(() => {
|
| 214 |
if (!loading && user && profile) {
|
|
|
|
| 225 |
period: '/mo',
|
| 226 |
desc: 'Perfect for small teams exploring AI helpdesk.',
|
| 227 |
cta: 'Get Started Free',
|
| 228 |
+
ctaStyle: 'border border-gray-200 text-gray-700 hover:border-emerald-900 hover:text-emerald-800',
|
| 229 |
features: ['Up to 50 tickets/mo', 'Basic AI Categorization', 'Email Support', '1 Team Member', 'Public API Access'],
|
| 230 |
popular: false,
|
| 231 |
},
|
|
|
|
| 235 |
period: '/mo',
|
| 236 |
desc: 'For growing IT teams needing full automation.',
|
| 237 |
cta: 'Start Free Trial',
|
| 238 |
+
ctaStyle: 'bg-emerald-900 text-white hover:bg-emerald-800 shadow-lg shadow-emerald-900/20',
|
| 239 |
features: ['Up to 500 tickets/mo', 'Advanced AI Parsing', 'Priority Detection Engine', 'Duplicate Detection', '5 Team Members', 'Priority Email Support'],
|
| 240 |
popular: true,
|
| 241 |
},
|
|
|
|
| 245 |
period: '',
|
| 246 |
desc: 'For large organizations with complex IT landscapes.',
|
| 247 |
cta: 'Contact Sales',
|
| 248 |
+
ctaStyle: 'border border-gray-200 text-gray-700 hover:border-emerald-900 hover:text-emerald-800',
|
| 249 |
features: ['Unlimited tickets', 'Custom AI Fine-Tuning', 'SSO & Audit Logs', 'Dedicated SLA Manager', 'Unlimited Members', 'VAPT & Compliance Reports'],
|
| 250 |
popular: false,
|
| 251 |
},
|
|
|
|
| 316 |
</button>
|
| 317 |
<button
|
| 318 |
onClick={() => setShowDemo(true)}
|
| 319 |
+
className="text-sm font-semibold text-emerald-800 border border-emerald-200 px-4 py-2 rounded-lg hover:bg-emerald-50 transition-all flex items-center gap-1.5"
|
| 320 |
>
|
| 321 |
+
<Play className="w-3.5 h-3.5 fill-emerald-700" /> Watch Demo
|
| 322 |
</button>
|
| 323 |
<button
|
| 324 |
onClick={() => navigate('/admin-signup')}
|
| 325 |
+
className="bg-emerald-900 hover:bg-emerald-800 text-white px-5 py-2.5 rounded-lg text-sm font-semibold transition-all shadow-lg shadow-emerald-900/20"
|
| 326 |
>
|
| 327 |
Get Started Free
|
| 328 |
</button>
|
|
|
|
| 345 |
<a href="#how-it-works" onClick={() => setIsMenuOpen(false)} className="block text-base font-semibold text-gray-700 hover:text-emerald-800 py-2">How It Works</a>
|
| 346 |
<a href="#pricing" onClick={() => setIsMenuOpen(false)} className="block text-base font-semibold text-gray-700 hover:text-emerald-800 py-2">Pricing</a>
|
| 347 |
<div className="pt-4 flex flex-col gap-3 border-t border-gray-100">
|
| 348 |
+
<button onClick={() => { setIsMenuOpen(false); setShowDemo(true); }} className="w-full text-center py-2.5 text-emerald-800 font-semibold border border-emerald-200 rounded-lg flex items-center justify-center gap-2">
|
| 349 |
+
<Play className="w-4 h-4 fill-emerald-700" /> Watch Demo
|
| 350 |
</button>
|
| 351 |
<button onClick={() => navigate('/login')} className="w-full text-center py-2.5 text-gray-700 font-semibold border border-gray-100 rounded-lg">
|
| 352 |
Sign In
|
| 353 |
</button>
|
| 354 |
+
<button onClick={() => navigate('/admin-signup')} className="w-full bg-emerald-900 text-white py-3 rounded-lg font-semibold shadow">
|
| 355 |
Get Started Free
|
| 356 |
</button>
|
| 357 |
</div>
|
|
|
|
| 382 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6">
|
| 383 |
<button
|
| 384 |
onClick={() => navigate('/admin-signup')}
|
| 385 |
+
className="w-full sm:w-auto px-8 py-4 bg-emerald-900 text-white rounded-xl font-bold shadow-xl shadow-emerald-900/25 hover:bg-emerald-800 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 text-base"
|
| 386 |
>
|
| 387 |
+
Get Started Free <ArrowRight className="w-5 h-5" />
|
| 388 |
</button>
|
| 389 |
<button
|
| 390 |
onClick={() => setShowDemo(true)}
|
| 391 |
+
className="w-full sm:w-auto px-8 py-4 bg-white text-gray-700 border border-gray-200 rounded-xl font-semibold hover:border-emerald-500 hover:text-emerald-700 transition-all flex items-center justify-center gap-2 text-base"
|
| 392 |
>
|
| 393 |
+
<Play className="w-4 h-4 fill-gray-500" /> Watch a Demo
|
| 394 |
</button>
|
| 395 |
</div>
|
| 396 |
|
|
|
|
| 427 |
<div className="mb-4">
|
| 428 |
<h3 className="text-sm font-bold text-gray-800 mb-1">Subject: Wifi down again in Lab 3??</h3>
|
| 429 |
<p className="text-sm text-gray-600 leading-relaxed">
|
| 430 |
+
Hey support, the wifi in <span className="bg-yellow-100 px-1 rounded">downstairs lab 3</span> is acting up again.
|
| 431 |
Can't connect at all. Class starts in 20 mins, need this fixed ASAP!<br /><br />
|
| 432 |
Thanks,<br />Sarah
|
| 433 |
</p>
|
|
|
|
| 658 |
<div className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-500/10 text-emerald-400 rounded-full text-[10px] md:text-xs font-bold uppercase tracking-widest border border-emerald-500/20 mb-4 md:mb-6">
|
| 659 |
The Journey
|
| 660 |
</div>
|
| 661 |
+
<h2 className="text-3xl sm:text-4xl md:text-6xl font-black text-white tracking-tight leading-[0.9] mb-8 md:mb-12 italic uppercase">
|
| 662 |
From Chaos <br />
|
| 663 |
to <span className="text-emerald-500">Clarity.</span>
|
| 664 |
</h2>
|
|
|
|
| 669 |
key={idx}
|
| 670 |
onMouseEnter={() => setActiveStep(idx)}
|
| 671 |
onClick={() => setActiveStep(idx)}
|
| 672 |
+
className={`group cursor-pointer p-6 rounded-3xl transition-all duration-500 border ${activeStep === idx
|
| 673 |
? 'bg-white/10 border-white/20 shadow-2xl shadow-black/20'
|
| 674 |
: 'bg-transparent border-transparent hover:bg-white/5 opacity-40 hover:opacity-100'
|
| 675 |
}`}
|
| 676 |
>
|
| 677 |
<div className="flex items-start gap-4 md:gap-6">
|
| 678 |
+
<div className={`shrink-0 w-10 md:w-12 h-10 md:h-12 rounded-xl md:rounded-2xl flex items-center justify-center font-black text-lg md:text-xl italic transition-all duration-500 ${activeStep === idx ? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20 rotate-12 scale-110' : 'bg-white/10 text-white/40'}`}>
|
| 679 |
{step.num}
|
| 680 |
</div>
|
| 681 |
<div>
|
| 682 |
+
<h3 className={`text-xl font-black italic uppercase transition-colors duration-500 ${activeStep === idx ? 'text-white' : 'text-white/60'}`}>
|
| 683 |
{step.title}
|
| 684 |
</h3>
|
| 685 |
+
{activeStep === idx && (
|
| 686 |
+
<motion.p
|
| 687 |
+
initial={{ opacity: 0, height: 0 }}
|
| 688 |
+
animate={{ opacity: 1, height: 'auto' }}
|
| 689 |
+
className="text-white/60 text-sm mt-3 leading-relaxed max-w-sm"
|
| 690 |
+
>
|
| 691 |
+
{step.desc}
|
| 692 |
+
</motion.p>
|
| 693 |
+
)}
|
| 694 |
</div>
|
| 695 |
</div>
|
| 696 |
</div>
|
|
|
|
| 701 |
{/* Right: Visual Display */}
|
| 702 |
<div className="w-full md:w-1/2 h-[350px] md:h-[500px] relative">
|
| 703 |
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-[32px] md:rounded-[40px] border border-white/5 backdrop-blur-3xl overflow-hidden p-6 md:p-12 flex items-center justify-center">
|
| 704 |
+
<AnimatePresence mode="wait">
|
| 705 |
<motion.div
|
| 706 |
key={activeStep}
|
| 707 |
+
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
| 708 |
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 709 |
+
exit={{ opacity: 0, scale: 1.1, y: -20 }}
|
| 710 |
+
transition={{ type: 'spring', damping: 20, stiffness: 100 }}
|
| 711 |
+
className="w-full h-full flex flex-col items-center justify-center"
|
| 712 |
>
|
| 713 |
<div className="mb-6 inline-flex items-center gap-2 px-4 py-1.5 bg-white/5 text-white/50 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
|
| 714 |
<div className={`w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse`} />
|
|
|
|
| 772 |
<button
|
| 773 |
onClick={() => handlePricingClick(name)}
|
| 774 |
disabled={isRedirecting && name === 'Growth'}
|
| 775 |
+
className={`w-full flex items-center justify-center gap-2 py-3 rounded-xl font-semibold transition-all mb-8 text-sm ${ctaStyle} ${isRedirecting && name === 'Growth' ? 'opacity-80 cursor-not-allowed' : ''}`}
|
| 776 |
>
|
| 777 |
{isRedirecting && name === 'Growth' ? (
|
| 778 |
<>
|
|
|
|
| 815 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
| 816 |
<button
|
| 817 |
onClick={() => navigate('/admin-signup')}
|
| 818 |
+
className="w-full sm:w-auto px-8 py-4 bg-white text-emerald-900 font-bold rounded-xl hover:bg-green-50 transition-all shadow-xl"
|
| 819 |
>
|
| 820 |
Get Started Free
|
| 821 |
</button>
|
| 822 |
<button
|
| 823 |
onClick={() => setShowDemo(true)}
|
| 824 |
+
className="w-full sm:w-auto px-8 py-4 border border-white/30 text-white font-semibold rounded-xl hover:bg-white/10 transition-all flex items-center justify-center gap-2"
|
| 825 |
>
|
| 826 |
+
<Play className="w-4 h-4 fill-white" /> Watch Demo
|
| 827 |
</button>
|
| 828 |
</div>
|
| 829 |
<div className="mt-8">
|
Frontend/src/pages/Signup.jsx
CHANGED
|
@@ -3,7 +3,6 @@ import { useNavigate, Link } from "react-router-dom";
|
|
| 3 |
import useAuthStore from "../store/authStore";
|
| 4 |
import { supabase } from "../lib/supabaseClient";
|
| 5 |
import { Eye, EyeOff, BrainCircuit, ArrowRight, Loader2, CheckCircle2, ChevronDown, Search, Building2, ArrowLeft } from "lucide-react";
|
| 6 |
-
import { getPasswordValidation, getPasswordValidationMessage } from "../utils/passwordValidation";
|
| 7 |
|
| 8 |
function Signup() {
|
| 9 |
const [email, setEmail] = useState("");
|
|
@@ -28,10 +27,6 @@ function Signup() {
|
|
| 28 |
const dropdownRef = useRef(null);
|
| 29 |
const navigate = useNavigate();
|
| 30 |
const { signup, user, profile } = useAuthStore();
|
| 31 |
-
const passwordRules = { minLength: 6 };
|
| 32 |
-
const passwordChecks = getPasswordValidation(password, passwordRules);
|
| 33 |
-
const passwordWarning = getPasswordValidationMessage(passwordChecks, passwordRules);
|
| 34 |
-
const confirmPasswordWarning = confirmPassword && password !== confirmPassword ? "Passwords do not match." : "";
|
| 35 |
|
| 36 |
// Fetch and subscribe to companies
|
| 37 |
useEffect(() => {
|
|
@@ -118,13 +113,13 @@ function Signup() {
|
|
| 118 |
return;
|
| 119 |
}
|
| 120 |
|
| 121 |
-
if (
|
| 122 |
-
setError(
|
| 123 |
return;
|
| 124 |
}
|
| 125 |
|
| 126 |
-
if (
|
| 127 |
-
setError(
|
| 128 |
return;
|
| 129 |
}
|
| 130 |
|
|
@@ -326,11 +321,6 @@ function Signup() {
|
|
| 326 |
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 327 |
</button>
|
| 328 |
</div>
|
| 329 |
-
{password && (
|
| 330 |
-
<p aria-live="polite" className={`mt-2 text-[11px] font-semibold ${passwordWarning ? "text-red-600" : "text-emerald-700"}`}>
|
| 331 |
-
{passwordWarning || "Password looks good."}
|
| 332 |
-
</p>
|
| 333 |
-
)}
|
| 334 |
</div>
|
| 335 |
<div className="relative">
|
| 336 |
<label className="block mb-2" style={labelStyle}>Confirm</label>
|
|
@@ -341,11 +331,6 @@ function Signup() {
|
|
| 341 |
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 342 |
</button>
|
| 343 |
</div>
|
| 344 |
-
{confirmPasswordWarning && (
|
| 345 |
-
<p aria-live="polite" className="mt-2 text-[11px] font-semibold text-red-600">
|
| 346 |
-
{confirmPasswordWarning}
|
| 347 |
-
</p>
|
| 348 |
-
)}
|
| 349 |
</div>
|
| 350 |
</div>
|
| 351 |
|
|
|
|
| 3 |
import useAuthStore from "../store/authStore";
|
| 4 |
import { supabase } from "../lib/supabaseClient";
|
| 5 |
import { Eye, EyeOff, BrainCircuit, ArrowRight, Loader2, CheckCircle2, ChevronDown, Search, Building2, ArrowLeft } from "lucide-react";
|
|
|
|
| 6 |
|
| 7 |
function Signup() {
|
| 8 |
const [email, setEmail] = useState("");
|
|
|
|
| 27 |
const dropdownRef = useRef(null);
|
| 28 |
const navigate = useNavigate();
|
| 29 |
const { signup, user, profile } = useAuthStore();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
// Fetch and subscribe to companies
|
| 32 |
useEffect(() => {
|
|
|
|
| 113 |
return;
|
| 114 |
}
|
| 115 |
|
| 116 |
+
if (password.length < 6) {
|
| 117 |
+
setError("Password must be at least 6 characters long.");
|
| 118 |
return;
|
| 119 |
}
|
| 120 |
|
| 121 |
+
if (password !== confirmPassword) {
|
| 122 |
+
setError("Passwords do not match.");
|
| 123 |
return;
|
| 124 |
}
|
| 125 |
|
|
|
|
| 321 |
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 322 |
</button>
|
| 323 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
</div>
|
| 325 |
<div className="relative">
|
| 326 |
<label className="block mb-2" style={labelStyle}>Confirm</label>
|
|
|
|
| 331 |
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 332 |
</button>
|
| 333 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
</div>
|
| 335 |
</div>
|
| 336 |
|
Frontend/src/services/aiAssistant.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
import {
|
|
|
|
| 2 |
|
| 3 |
// ============================================================
|
| 4 |
// MULTI-API FAILOVER CONFIGURATION
|
|
@@ -7,30 +8,45 @@ import { supabase } from "../lib/supabaseClient";
|
|
| 7 |
// ============================================================
|
| 8 |
|
| 9 |
const buildConfigList = () => {
|
|
|
|
| 10 |
const configs = [];
|
| 11 |
|
| 12 |
// Priority 1: Native Gemini — try modern flash models
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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 |
-
|
| 27 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 33 |
-
configs.push({ provider: 'groq', model });
|
| 34 |
});
|
| 35 |
|
| 36 |
return configs;
|
|
@@ -41,8 +57,11 @@ const buildConfigList = () => {
|
|
| 41 |
// PROVIDER HANDLERS
|
| 42 |
// ============================================================
|
| 43 |
|
| 44 |
-
const
|
| 45 |
-
const
|
|
|
|
|
|
|
|
|
|
| 46 |
const parts = [{ text: msg.text || "" }];
|
| 47 |
if (msg.image) {
|
| 48 |
const [mime, data] = msg.image.split(';base64,');
|
|
@@ -51,110 +70,72 @@ const buildGeminiMessages = (promptText, history, image) => {
|
|
| 51 |
return { role: msg.role === 'bot' ? 'model' : 'user', parts };
|
| 52 |
});
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
const messageParts = [{ text: promptText }];
|
| 55 |
if (image) {
|
| 56 |
const [mime, data] = image.split(';base64,');
|
| 57 |
messageParts.push({ inlineData: { mimeType: mime.split(':')[1] || 'image/png', data } });
|
| 58 |
}
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
: [{ role: 'user', parts: messageParts }];
|
| 63 |
};
|
| 64 |
|
| 65 |
-
const
|
| 66 |
-
const messages = history.map(msg => {
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 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 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
}
|
| 143 |
-
|
| 144 |
-
return
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
} catch (error) {
|
| 159 |
const isRateLimit = error.status === 429
|
| 160 |
|| error.message?.includes('429')
|
|
|
|
| 1 |
+
import { GoogleGenerativeAI } from "@google/generative-ai";
|
| 2 |
+
import { API_CONFIG } from "../config";
|
| 3 |
|
| 4 |
// ============================================================
|
| 5 |
// MULTI-API FAILOVER CONFIGURATION
|
|
|
|
| 8 |
// ============================================================
|
| 9 |
|
| 10 |
const buildConfigList = () => {
|
| 11 |
+
const env = import.meta.env;
|
| 12 |
const configs = [];
|
| 13 |
|
| 14 |
// Priority 1: Native Gemini — try modern flash models
|
| 15 |
+
const geminiKeys = [
|
| 16 |
+
env.VITE_GEMINI_API_KEY_1, env.VITE_GEMINI_API_KEY_2,
|
| 17 |
+
env.VITE_GEMINI_API_KEY_3, env.VITE_GEMINI_API_KEY_4
|
| 18 |
+
].filter(Boolean);
|
| 19 |
+
// Try each key with gemini-2.5-flash first (most robust, active free tier), then gemini-2.5-flash-lite
|
| 20 |
+
geminiKeys.forEach(key => {
|
| 21 |
+
configs.push({ provider: 'gemini', key, model: 'gemini-2.5-flash' });
|
| 22 |
+
configs.push({ provider: 'gemini', key, model: 'gemini-2.5-flash-lite' });
|
| 23 |
+
configs.push({ provider: 'gemini', key, model: 'gemini-2.0-flash' });
|
| 24 |
+
});
|
| 25 |
|
| 26 |
// Priority 2: OpenRouter — updated model slugs (verified working as of 2025)
|
| 27 |
+
const openrouterKeys = [
|
| 28 |
+
env.VITE_OPENROUTER_API_KEY_1, env.VITE_OPENROUTER_API_KEY_2,
|
| 29 |
+
env.VITE_OPENROUTER_API_KEY_3, env.VITE_OPENROUTER_API_KEY_4,
|
| 30 |
+
].filter(Boolean);
|
| 31 |
const openrouterModels = [
|
| 32 |
'meta-llama/llama-3.2-3b-instruct:free',
|
| 33 |
'microsoft/phi-3-mini-128k-instruct:free',
|
| 34 |
'mistralai/mistral-7b-instruct:free',
|
| 35 |
'google/gemma-2-9b-it:free',
|
| 36 |
];
|
| 37 |
+
openrouterKeys.forEach((key, idx) => {
|
| 38 |
+
// Each key tries two models for extra redundancy
|
| 39 |
+
configs.push({ provider: 'openrouter', key, model: openrouterModels[idx % openrouterModels.length] });
|
| 40 |
+
configs.push({ provider: 'openrouter', key, model: openrouterModels[(idx + 1) % openrouterModels.length] });
|
| 41 |
});
|
| 42 |
|
| 43 |
// Priority 3: Groq — use stable, currently-available models
|
| 44 |
+
const groqKeys = [
|
| 45 |
+
env.VITE_GROQ_API_KEY_1, env.VITE_GROQ_API_KEY_2, env.VITE_GROQ_API_KEY_3
|
| 46 |
+
].filter(Boolean);
|
| 47 |
const groqModels = ['llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'];
|
| 48 |
+
groqKeys.forEach((key, idx) => {
|
| 49 |
+
configs.push({ provider: 'groq', key, model: groqModels[idx % groqModels.length] });
|
| 50 |
});
|
| 51 |
|
| 52 |
return configs;
|
|
|
|
| 57 |
// PROVIDER HANDLERS
|
| 58 |
// ============================================================
|
| 59 |
|
| 60 |
+
const callGemini = async (config, promptText, history, image) => {
|
| 61 |
+
const genAI = new GoogleGenerativeAI(config.key);
|
| 62 |
+
const model = genAI.getGenerativeModel({ model: config.model });
|
| 63 |
+
|
| 64 |
+
let formattedHistory = history.map(msg => {
|
| 65 |
const parts = [{ text: msg.text || "" }];
|
| 66 |
if (msg.image) {
|
| 67 |
const [mime, data] = msg.image.split(';base64,');
|
|
|
|
| 70 |
return { role: msg.role === 'bot' ? 'model' : 'user', parts };
|
| 71 |
});
|
| 72 |
|
| 73 |
+
// Gemini requires history to start with 'user' role
|
| 74 |
+
const firstUserIdx = formattedHistory.findIndex(h => h.role === 'user');
|
| 75 |
+
if (firstUserIdx > 0) formattedHistory = formattedHistory.slice(firstUserIdx);
|
| 76 |
+
else if (firstUserIdx === -1) formattedHistory = [];
|
| 77 |
+
|
| 78 |
+
const chat = model.startChat({ history: formattedHistory, generationConfig: { maxOutputTokens: 2048 } });
|
| 79 |
+
|
| 80 |
const messageParts = [{ text: promptText }];
|
| 81 |
if (image) {
|
| 82 |
const [mime, data] = image.split(';base64,');
|
| 83 |
messageParts.push({ inlineData: { mimeType: mime.split(':')[1] || 'image/png', data } });
|
| 84 |
}
|
| 85 |
|
| 86 |
+
const result = await chat.sendMessage(messageParts);
|
| 87 |
+
return result.response.text();
|
|
|
|
| 88 |
};
|
| 89 |
|
| 90 |
+
const callOpenAICompat = async (config, promptText, history, image, baseUrl, extraHeaders = {}) => {
|
| 91 |
+
const messages = history.map(msg => ({
|
| 92 |
+
role: msg.role === 'bot' ? 'assistant' : 'user',
|
| 93 |
+
content: msg.text || ""
|
| 94 |
+
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
const userContent = image
|
| 97 |
? [{ type: "text", text: promptText }, { type: "image_url", image_url: { url: image } }]
|
| 98 |
: promptText;
|
| 99 |
|
| 100 |
messages.push({ role: "user", content: userContent });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
| 103 |
+
method: 'POST',
|
| 104 |
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.key}`, ...extraHeaders },
|
| 105 |
+
body: JSON.stringify({ model: config.model, messages, max_tokens: 2048 })
|
| 106 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
if (!response.ok) {
|
| 109 |
+
const err = new Error(`HTTP ${response.status}`);
|
| 110 |
+
err.status = response.status;
|
| 111 |
+
throw err;
|
| 112 |
}
|
| 113 |
+
const data = await response.json();
|
| 114 |
+
return data.choices?.[0]?.message?.content || "No response received.";
|
| 115 |
};
|
| 116 |
|
| 117 |
// Core failover runner — shared by both exported functions
|
| 118 |
const runWithFailover = async (promptText, history, image) => {
|
| 119 |
const configList = buildConfigList();
|
| 120 |
+
if (configList.length === 0) throw new Error("No AI API keys configured in .env");
|
| 121 |
|
| 122 |
for (let i = 0; i < configList.length; i++) {
|
| 123 |
const config = configList[i];
|
| 124 |
console.log(`[AI Failover] Trying ${i + 1}/${configList.length}: ${config.provider} (${config.model})`);
|
| 125 |
|
| 126 |
try {
|
| 127 |
+
if (config.provider === 'gemini') {
|
| 128 |
+
return await callGemini(config, promptText, history, image);
|
| 129 |
+
} else if (config.provider === 'openrouter') {
|
| 130 |
+
return await callOpenAICompat(config, promptText, history, image,
|
| 131 |
+
'https://openrouter.ai/api/v1',
|
| 132 |
+
{ 'HTTP-Referer': API_CONFIG.FRONTEND_URL, 'X-Title': 'AI Helpdesk' }
|
| 133 |
+
);
|
| 134 |
+
} else if (config.provider === 'groq') {
|
| 135 |
+
return await callOpenAICompat(config, promptText, history, null, // Groq = text only
|
| 136 |
+
'https://api.groq.com/openai/v1'
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
} catch (error) {
|
| 140 |
const isRateLimit = error.status === 429
|
| 141 |
|| error.message?.includes('429')
|
Frontend/src/services/api.js
CHANGED
|
@@ -2,7 +2,7 @@ import axios from 'axios';
|
|
| 2 |
import { MOCK_TICKETS } from './mockData';
|
| 3 |
import { API_CONFIG } from '../config';
|
| 4 |
|
| 5 |
-
const USE_MOCK =
|
| 6 |
const API_BASE_URL = API_CONFIG.BACKEND_URL;
|
| 7 |
|
| 8 |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -38,69 +38,38 @@ const setStorage = (key, data) => {
|
|
| 38 |
}
|
| 39 |
};
|
| 40 |
|
| 41 |
-
// Shared mock logic for createTicket
|
| 42 |
-
const createTicketMock = (ticketData) => {
|
| 43 |
-
const tickets = getStorage('tickets', MOCK_TICKETS);
|
| 44 |
-
const newTicket = {
|
| 45 |
-
ticket_id: "TCKT-" + Math.floor(Math.random() * 10000),
|
| 46 |
-
status: 'Open',
|
| 47 |
-
createdAt: new Date().toISOString(),
|
| 48 |
-
...ticketData,
|
| 49 |
-
messages: [
|
| 50 |
-
{
|
| 51 |
-
sender: 'user',
|
| 52 |
-
message: ticketData.description || ticketData.summary || '',
|
| 53 |
-
timestamp: new Date().toISOString()
|
| 54 |
-
}
|
| 55 |
-
]
|
| 56 |
-
};
|
| 57 |
-
tickets.unshift(newTicket); // Add to beginning
|
| 58 |
-
setStorage('tickets', tickets);
|
| 59 |
-
return { data: newTicket };
|
| 60 |
-
};
|
| 61 |
-
|
| 62 |
export const api = {
|
| 63 |
// Login and Signup have been fully migrated to Supabase via authStore.js
|
| 64 |
// Ensure that no component tries to use api.login or api.signup anymore.
|
| 65 |
|
|
|
|
| 66 |
getTickets: async () => {
|
| 67 |
if (USE_MOCK) {
|
| 68 |
await delay(500);
|
| 69 |
return getStorage('tickets', MOCK_TICKETS);
|
| 70 |
}
|
| 71 |
-
try {
|
| 72 |
-
const response = await axios.get(`${API_BASE_URL}/tickets`);
|
| 73 |
-
const data = response?.data;
|
| 74 |
-
|
| 75 |
-
// Normalize to the mock shape: an array of tickets
|
| 76 |
-
if (Array.isArray(data)) return data;
|
| 77 |
-
if (data && Array.isArray(data.data)) return data.data;
|
| 78 |
-
if (data && Array.isArray(data.tickets)) return data.tickets;
|
| 79 |
-
|
| 80 |
-
return data;
|
| 81 |
-
} catch (error) {
|
| 82 |
-
console.error("Backend unavailable, falling back to mock:", error);
|
| 83 |
-
await delay(500);
|
| 84 |
-
return getStorage('tickets', MOCK_TICKETS);
|
| 85 |
-
}
|
| 86 |
},
|
| 87 |
|
| 88 |
createTicket: async (ticketData) => {
|
| 89 |
if (USE_MOCK) {
|
| 90 |
await delay(800);
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
},
|
| 106 |
|
|
|
|
| 2 |
import { MOCK_TICKETS } from './mockData';
|
| 3 |
import { API_CONFIG } from '../config';
|
| 4 |
|
| 5 |
+
const USE_MOCK = true;
|
| 6 |
const API_BASE_URL = API_CONFIG.BACKEND_URL;
|
| 7 |
|
| 8 |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
| 38 |
}
|
| 39 |
};
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
export const api = {
|
| 42 |
// Login and Signup have been fully migrated to Supabase via authStore.js
|
| 43 |
// Ensure that no component tries to use api.login or api.signup anymore.
|
| 44 |
|
| 45 |
+
|
| 46 |
getTickets: async () => {
|
| 47 |
if (USE_MOCK) {
|
| 48 |
await delay(500);
|
| 49 |
return getStorage('tickets', MOCK_TICKETS);
|
| 50 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
},
|
| 52 |
|
| 53 |
createTicket: async (ticketData) => {
|
| 54 |
if (USE_MOCK) {
|
| 55 |
await delay(800);
|
| 56 |
+
const tickets = getStorage('tickets', MOCK_TICKETS);
|
| 57 |
+
const newTicket = {
|
| 58 |
+
ticket_id: "TCKT-" + Math.floor(Math.random() * 10000),
|
| 59 |
+
status: 'Open',
|
| 60 |
+
createdAt: new Date().toISOString(),
|
| 61 |
+
...ticketData,
|
| 62 |
+
messages: [
|
| 63 |
+
{
|
| 64 |
+
sender: 'user',
|
| 65 |
+
message: ticketData.description || ticketData.summary || '',
|
| 66 |
+
timestamp: new Date().toISOString()
|
| 67 |
+
}
|
| 68 |
+
]
|
| 69 |
+
};
|
| 70 |
+
tickets.unshift(newTicket); // Add to beginning
|
| 71 |
+
setStorage('tickets', tickets);
|
| 72 |
+
return { data: newTicket };
|
| 73 |
}
|
| 74 |
},
|
| 75 |
|
Frontend/src/store/authStore.js
CHANGED
|
@@ -1,55 +1,10 @@
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { persist } from 'zustand/middleware';
|
| 3 |
import { supabase } from '../lib/supabaseClient';
|
| 4 |
-
import { API_CONFIG } from '../config';
|
| 5 |
import useTicketStore from './ticketStore';
|
| 6 |
|
| 7 |
-
const BACKEND_URL = API_CONFIG.BACKEND_URL;
|
| 8 |
-
|
| 9 |
-
const verifyServerCookieSession = async () => {
|
| 10 |
-
try {
|
| 11 |
-
const res = await fetch(`${BACKEND_URL}/auth/me`, {
|
| 12 |
-
method: 'GET',
|
| 13 |
-
credentials: 'include',
|
| 14 |
-
headers: { Accept: 'application/json' },
|
| 15 |
-
});
|
| 16 |
-
if (!res.ok) return null;
|
| 17 |
-
const body = await res.json();
|
| 18 |
-
return body?.user || null;
|
| 19 |
-
} catch (e) {
|
| 20 |
-
console.warn('Server cookie session check failed:', e?.message || e);
|
| 21 |
-
return null;
|
| 22 |
-
}
|
| 23 |
-
};
|
| 24 |
-
|
| 25 |
-
const mirrorBackendAuth = async (path, payload) => {
|
| 26 |
-
try {
|
| 27 |
-
await fetch(`${BACKEND_URL}${path}`, {
|
| 28 |
-
method: 'POST',
|
| 29 |
-
credentials: 'include',
|
| 30 |
-
headers: { 'Content-Type': 'application/json' },
|
| 31 |
-
body: JSON.stringify(payload),
|
| 32 |
-
});
|
| 33 |
-
} catch (e) {
|
| 34 |
-
console.warn(`Backend auth ${path} failed:`, e?.message || e);
|
| 35 |
-
}
|
| 36 |
-
};
|
| 37 |
-
|
| 38 |
let currentUserPromise = null;
|
| 39 |
|
| 40 |
-
const getProfileCache = (profile) => {
|
| 41 |
-
if (!profile?.id) return null;
|
| 42 |
-
|
| 43 |
-
return {
|
| 44 |
-
id: profile.id,
|
| 45 |
-
email: profile.email,
|
| 46 |
-
full_name: profile.full_name,
|
| 47 |
-
company: profile.company,
|
| 48 |
-
company_id: profile.company_id,
|
| 49 |
-
profile_picture: profile.profile_picture,
|
| 50 |
-
};
|
| 51 |
-
};
|
| 52 |
-
|
| 53 |
const useAuthStore = create(
|
| 54 |
persist(
|
| 55 |
(set, get) => ({
|
|
@@ -66,25 +21,38 @@ const useAuthStore = create(
|
|
| 66 |
if (!user) return null;
|
| 67 |
|
| 68 |
const metadata = user.user_metadata || {};
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
//
|
| 72 |
-
//
|
| 73 |
-
|
| 74 |
-
if (
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
| 78 |
const instantProfile = {
|
| 79 |
id: user.id,
|
| 80 |
email: user.email,
|
| 81 |
-
full_name: metadata.full_name || 'User',
|
| 82 |
-
role: 'user',
|
| 83 |
-
status: 'pending_email_verification',
|
| 84 |
company: metadata.company || ''
|
| 85 |
};
|
| 86 |
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
set({ profile: instantProfile });
|
| 89 |
return instantProfile;
|
| 90 |
},
|
|
@@ -121,19 +89,13 @@ const useAuthStore = create(
|
|
| 121 |
currentUserPromise = (async () => {
|
| 122 |
try {
|
| 123 |
set({ isCheckingSession: true });
|
| 124 |
-
const cookieUser = await verifyServerCookieSession();
|
| 125 |
-
if (cookieUser) {
|
| 126 |
-
set({ user: cookieUser });
|
| 127 |
-
await get().getProfile(cookieUser);
|
| 128 |
-
return cookieUser;
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
const { data: { user }, error } = await supabase.auth.getUser();
|
| 132 |
if (error) throw error;
|
| 133 |
|
| 134 |
if (user) {
|
| 135 |
set({ user });
|
| 136 |
-
await
|
|
|
|
| 137 |
} else {
|
| 138 |
set({ user: null, profile: null });
|
| 139 |
}
|
|
@@ -155,8 +117,6 @@ const useAuthStore = create(
|
|
| 155 |
set({ loading: true });
|
| 156 |
console.log("Attempting login for:", email);
|
| 157 |
try {
|
| 158 |
-
await mirrorBackendAuth('/auth/login', { email, password });
|
| 159 |
-
|
| 160 |
const { data, error } = await supabase.auth.signInWithPassword({
|
| 161 |
email,
|
| 162 |
password,
|
|
@@ -245,14 +205,6 @@ const useAuthStore = create(
|
|
| 245 |
console.log("Starting signup for:", email);
|
| 246 |
|
| 247 |
try {
|
| 248 |
-
await mirrorBackendAuth('/auth/signup', {
|
| 249 |
-
email,
|
| 250 |
-
password,
|
| 251 |
-
full_name: fullName,
|
| 252 |
-
role,
|
| 253 |
-
company,
|
| 254 |
-
});
|
| 255 |
-
|
| 256 |
// 1. Auth Signup with Metadata
|
| 257 |
console.log("Step 1: Auth.signUp...");
|
| 258 |
const { data, error } = await supabase.auth.signUp({
|
|
@@ -293,15 +245,6 @@ const useAuthStore = create(
|
|
| 293 |
logout: async () => {
|
| 294 |
set({ loading: true });
|
| 295 |
try {
|
| 296 |
-
try {
|
| 297 |
-
await fetch(`${BACKEND_URL}/auth/logout`, {
|
| 298 |
-
method: 'POST',
|
| 299 |
-
credentials: 'include',
|
| 300 |
-
});
|
| 301 |
-
} catch (e) {
|
| 302 |
-
console.warn('Backend cookie logout failed:', e?.message || e);
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
const { error } = await supabase.auth.signOut();
|
| 306 |
if (error) throw error;
|
| 307 |
set({ user: null, profile: null });
|
|
@@ -350,8 +293,8 @@ const useAuthStore = create(
|
|
| 350 |
supabase.auth.onAuthStateChange(async (event, session) => {
|
| 351 |
console.log("Auth state change:", event);
|
| 352 |
if (session?.user) {
|
| 353 |
-
set({ user: session.user
|
| 354 |
-
|
| 355 |
} else {
|
| 356 |
set({ user: null, profile: null });
|
| 357 |
}
|
|
@@ -362,8 +305,9 @@ const useAuthStore = create(
|
|
| 362 |
{
|
| 363 |
name: 'auth-storage',
|
| 364 |
partialize: (state) => ({
|
| 365 |
-
//
|
| 366 |
-
|
|
|
|
| 367 |
}),
|
| 368 |
}
|
| 369 |
)
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { persist } from 'zustand/middleware';
|
| 3 |
import { supabase } from '../lib/supabaseClient';
|
|
|
|
| 4 |
import useTicketStore from './ticketStore';
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
let currentUserPromise = null;
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
const useAuthStore = create(
|
| 9 |
persist(
|
| 10 |
(set, get) => ({
|
|
|
|
| 21 |
if (!user) return null;
|
| 22 |
|
| 23 |
const metadata = user.user_metadata || {};
|
| 24 |
+
const currentProfile = get().profile;
|
| 25 |
+
|
| 26 |
+
// 1. Resolve FROM METADATA or PERSISTED state
|
| 27 |
+
// Priority 1: If we have a persisted session for THIS user and it's active, keep it
|
| 28 |
+
// to prevent temporary lobbies during refresh/tab switching.
|
| 29 |
+
if (currentProfile && currentProfile.id === user.id && currentProfile.status === 'active') {
|
| 30 |
+
console.log("Active profile retained from state.");
|
| 31 |
+
// Background fetch to ensure session is still valid/synced
|
| 32 |
+
get()._syncProfile(user.id);
|
| 33 |
+
return currentProfile;
|
| 34 |
}
|
| 35 |
|
| 36 |
+
// Priority 2: Use Auth Metadata (Instant fallback)
|
| 37 |
+
const isMasterAdmin = user.email === 'masteradmin@helpdesk.ai';
|
| 38 |
+
|
| 39 |
const instantProfile = {
|
| 40 |
id: user.id,
|
| 41 |
email: user.email,
|
| 42 |
+
full_name: isMasterAdmin ? 'Master Admin' : (metadata.full_name || 'User'),
|
| 43 |
+
role: isMasterAdmin ? 'master_admin' : (metadata.role || 'user'),
|
| 44 |
+
status: isMasterAdmin ? 'active' : 'pending_email_verification',
|
| 45 |
company: metadata.company || ''
|
| 46 |
};
|
| 47 |
|
| 48 |
+
// 2. Sync with Database First before setting a fallback
|
| 49 |
+
// This prevents flashes of 'pending_email_verification' when returning from magic links
|
| 50 |
+
const dbProfile = await get()._syncProfile(user.id);
|
| 51 |
+
if (dbProfile) {
|
| 52 |
+
return dbProfile;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
console.log("Falling back to instant profile resolved from metadata:", instantProfile.role);
|
| 56 |
set({ profile: instantProfile });
|
| 57 |
return instantProfile;
|
| 58 |
},
|
|
|
|
| 89 |
currentUserPromise = (async () => {
|
| 90 |
try {
|
| 91 |
set({ isCheckingSession: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
const { data: { user }, error } = await supabase.auth.getUser();
|
| 93 |
if (error) throw error;
|
| 94 |
|
| 95 |
if (user) {
|
| 96 |
set({ user });
|
| 97 |
+
// Don't 'await' here because we want 'loading: false' ASAP
|
| 98 |
+
get().getProfile(user);
|
| 99 |
} else {
|
| 100 |
set({ user: null, profile: null });
|
| 101 |
}
|
|
|
|
| 117 |
set({ loading: true });
|
| 118 |
console.log("Attempting login for:", email);
|
| 119 |
try {
|
|
|
|
|
|
|
| 120 |
const { data, error } = await supabase.auth.signInWithPassword({
|
| 121 |
email,
|
| 122 |
password,
|
|
|
|
| 205 |
console.log("Starting signup for:", email);
|
| 206 |
|
| 207 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
// 1. Auth Signup with Metadata
|
| 209 |
console.log("Step 1: Auth.signUp...");
|
| 210 |
const { data, error } = await supabase.auth.signUp({
|
|
|
|
| 245 |
logout: async () => {
|
| 246 |
set({ loading: true });
|
| 247 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
const { error } = await supabase.auth.signOut();
|
| 249 |
if (error) throw error;
|
| 250 |
set({ user: null, profile: null });
|
|
|
|
| 293 |
supabase.auth.onAuthStateChange(async (event, session) => {
|
| 294 |
console.log("Auth state change:", event);
|
| 295 |
if (session?.user) {
|
| 296 |
+
set({ user: session.user });
|
| 297 |
+
get().getProfile(session.user);
|
| 298 |
} else {
|
| 299 |
set({ user: null, profile: null });
|
| 300 |
}
|
|
|
|
| 305 |
{
|
| 306 |
name: 'auth-storage',
|
| 307 |
partialize: (state) => ({
|
| 308 |
+
// We keep profile persisted for quick UI transitions,
|
| 309 |
+
// but session is handled by Supabase cookie/localStorage
|
| 310 |
+
profile: state.profile
|
| 311 |
}),
|
| 312 |
}
|
| 313 |
)
|
Frontend/src/store/ticketStore.js
CHANGED
|
@@ -3,19 +3,14 @@ import { persist } from 'zustand/middleware';
|
|
| 3 |
|
| 4 |
const useTicketStore = create(
|
| 5 |
persist(
|
| 6 |
-
(set
|
| 7 |
aiTicket: null,
|
| 8 |
activeTicket: null,
|
| 9 |
autoResolvedTickets: [], // For analytics
|
| 10 |
tickets: [], // Global queue for admins
|
| 11 |
notifications: [], // User notifications
|
| 12 |
-
wsConnected: false, // WebSocket connection status
|
| 13 |
-
|
| 14 |
setAITicket: (data) => set({ aiTicket: data }),
|
| 15 |
setActiveTicket: (ticket) => set({ activeTicket: ticket }),
|
| 16 |
-
|
| 17 |
-
setWsConnected: (connected) => set({ wsConnected: connected }),
|
| 18 |
-
|
| 19 |
addAutoResolvedTicket: (record) => set((state) => ({
|
| 20 |
autoResolvedTickets: [...state.autoResolvedTickets, record]
|
| 21 |
})),
|
|
@@ -35,68 +30,27 @@ const useTicketStore = create(
|
|
| 35 |
tickets: [...state.tickets, ticket]
|
| 36 |
};
|
| 37 |
}),
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
const exists = state.tickets.some(t => (t.id ?? t.ticket_id) === ticketId);
|
| 43 |
-
const tickets = exists
|
| 44 |
-
? state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...ticket } : t)
|
| 45 |
-
: [ticket, ...state.tickets];
|
| 46 |
-
const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
|
| 47 |
|
| 48 |
-
return {
|
| 49 |
-
tickets,
|
| 50 |
-
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...ticket } : state.activeTicket
|
| 51 |
-
};
|
| 52 |
-
}),
|
| 53 |
-
removeTicket: (ticketId) => set((state) => ({
|
| 54 |
-
tickets: state.tickets.filter(t => (t.id ?? t.ticket_id) !== ticketId),
|
| 55 |
-
activeTicket: (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId
|
| 56 |
-
? null
|
| 57 |
-
: state.activeTicket
|
| 58 |
-
})),
|
| 59 |
-
updateTicket: (ticketId, updates) => set((state) => {
|
| 60 |
-
const existingTicket = state.tickets.find(t => (t.id ?? t.ticket_id) === ticketId);
|
| 61 |
-
if (!existingTicket) return state;
|
| 62 |
-
const updatedTickets = state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...updates } : t);
|
| 63 |
-
const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
|
| 64 |
return {
|
| 65 |
tickets: updatedTickets,
|
| 66 |
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
|
| 67 |
};
|
| 68 |
}),
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
handleWsMessage: (msg) => {
|
| 77 |
-
if (!msg || !msg.type) return;
|
| 78 |
-
|
| 79 |
-
const { type, event, ticket, ticket_id } = msg;
|
| 80 |
-
|
| 81 |
-
switch (type) {
|
| 82 |
-
case "ticket_update": {
|
| 83 |
-
if (!ticket) break;
|
| 84 |
-
if (event === "created") {
|
| 85 |
-
// Avoid duplicates — use upsert
|
| 86 |
-
get().upsertTicket(ticket);
|
| 87 |
-
} else if (event === "updated") {
|
| 88 |
-
get().upsertTicket(ticket);
|
| 89 |
-
} else if (event === "deleted") {
|
| 90 |
-
get().removeTicket(ticket_id);
|
| 91 |
-
}
|
| 92 |
-
break;
|
| 93 |
-
}
|
| 94 |
-
default:
|
| 95 |
-
// Ignore unknown message types (e.g. heartbeat)
|
| 96 |
-
break;
|
| 97 |
-
}
|
| 98 |
-
},
|
| 99 |
-
|
| 100 |
appendMessage: (ticketId, message) => set((state) => {
|
| 101 |
const updatedTickets = state.tickets.map(t =>
|
| 102 |
t.ticket_id === ticketId
|
|
|
|
| 3 |
|
| 4 |
const useTicketStore = create(
|
| 5 |
persist(
|
| 6 |
+
(set) => ({
|
| 7 |
aiTicket: null,
|
| 8 |
activeTicket: null,
|
| 9 |
autoResolvedTickets: [], // For analytics
|
| 10 |
tickets: [], // Global queue for admins
|
| 11 |
notifications: [], // User notifications
|
|
|
|
|
|
|
| 12 |
setAITicket: (data) => set({ aiTicket: data }),
|
| 13 |
setActiveTicket: (ticket) => set({ activeTicket: ticket }),
|
|
|
|
|
|
|
|
|
|
| 14 |
addAutoResolvedTicket: (record) => set((state) => ({
|
| 15 |
autoResolvedTickets: [...state.autoResolvedTickets, record]
|
| 16 |
})),
|
|
|
|
| 30 |
tickets: [...state.tickets, ticket]
|
| 31 |
};
|
| 32 |
}),
|
| 33 |
+
updateTicket: (ticketId, updates) => set((state) => {
|
| 34 |
+
// eslint-disable-next-line no-unused-vars
|
| 35 |
+
const existingTicket = state.tickets.find(t => t.ticket_id === ticketId);
|
| 36 |
+
const updatedTickets = state.tickets.map(t => t.ticket_id === ticketId ? { ...t, ...updates } : t);
|
| 37 |
+
const shouldUpdateActive = state.activeTicket?.ticket_id === ticketId;
|
| 38 |
+
|
| 39 |
+
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
return {
|
| 43 |
tickets: updatedTickets,
|
| 44 |
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
|
| 45 |
};
|
| 46 |
}),
|
| 47 |
|
| 48 |
+
removeTicket: (ticketId) => set((state) => ({
|
| 49 |
+
tickets: state.tickets.filter(t => t.ticket_id !== ticketId),
|
| 50 |
+
activeTicket: state.activeTicket?.ticket_id === ticketId
|
| 51 |
+
? null
|
| 52 |
+
: state.activeTicket
|
| 53 |
+
})),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
appendMessage: (ticketId, message) => set((state) => {
|
| 55 |
const updatedTickets = state.tickets.map(t =>
|
| 56 |
t.ticket_id === ticketId
|
Frontend/src/user/components/RecentTickets.jsx
CHANGED
|
@@ -150,11 +150,6 @@ const RecentTickets = () => {
|
|
| 150 |
<p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
|
| 151 |
{ticket.summary || ticket.subject || ticket.description || "No description provided"}
|
| 152 |
</p>
|
| 153 |
-
{ticket?.metadata?.translation?.translated && (
|
| 154 |
-
<p style={{ fontSize: '11px', color: '#0369a1', margin: '4px 0 0' }}>
|
| 155 |
-
Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
|
| 156 |
-
</p>
|
| 157 |
-
)}
|
| 158 |
</td>
|
| 159 |
<td style={{ padding: '16px 28px' }}>
|
| 160 |
{getStatusBadge(ticket.status)}
|
|
|
|
| 150 |
<p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
|
| 151 |
{ticket.summary || ticket.subject || ticket.description || "No description provided"}
|
| 152 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
</td>
|
| 154 |
<td style={{ padding: '16px 28px' }}>
|
| 155 |
{getStatusBadge(ticket.status)}
|
Frontend/src/user/pages/AutoResolveChat.jsx
CHANGED
|
@@ -19,7 +19,6 @@ const AutoResolveChat = () => {
|
|
| 19 |
const { aiTicket } = useTicketStore();
|
| 20 |
const navigate = useNavigate();
|
| 21 |
const [messages, setMessages] = useState([]);
|
| 22 |
-
const [steps, setSteps] = useState([]);
|
| 23 |
const [isThinking, setIsThinking] = useState(false);
|
| 24 |
const [isFinal, setIsFinal] = useState(false);
|
| 25 |
const [inputText, setInputText] = useState('');
|
|
@@ -78,7 +77,7 @@ const AutoResolveChat = () => {
|
|
| 78 |
}
|
| 79 |
|
| 80 |
if (newSteps.length >= 2) {
|
| 81 |
-
|
| 82 |
} else {
|
| 83 |
const sentences = response
|
| 84 |
.replace(/\*\*/g, '')
|
|
@@ -88,12 +87,7 @@ const AutoResolveChat = () => {
|
|
| 88 |
.slice(0, 4);
|
| 89 |
|
| 90 |
if (sentences.length >= 2) {
|
| 91 |
-
|
| 92 |
-
id: i + 1,
|
| 93 |
-
task: s,
|
| 94 |
-
completed: false
|
| 95 |
-
}));
|
| 96 |
-
setSteps(sentenceSteps);
|
| 97 |
} else {
|
| 98 |
throw new Error("Could not parse steps from AI response.");
|
| 99 |
}
|
|
@@ -203,12 +197,6 @@ const AutoResolveChat = () => {
|
|
| 203 |
else recognition.start();
|
| 204 |
};
|
| 205 |
|
| 206 |
-
const toggleStep = (stepId) => {
|
| 207 |
-
setSteps(prev => prev.map(step =>
|
| 208 |
-
step.id === stepId ? { ...step, completed: !step.completed } : step
|
| 209 |
-
));
|
| 210 |
-
};
|
| 211 |
-
|
| 212 |
if (!aiTicket) return null;
|
| 213 |
|
| 214 |
return (
|
|
@@ -261,48 +249,6 @@ const AutoResolveChat = () => {
|
|
| 261 |
</div>
|
| 262 |
</div>
|
| 263 |
|
| 264 |
-
{/* Troubleshooting Steps */}
|
| 265 |
-
{steps.length > 0 && (
|
| 266 |
-
<div className="px-10 py-6 border-b border-white/40 bg-emerald-50/30">
|
| 267 |
-
<div className="flex items-center gap-2 mb-3">
|
| 268 |
-
<ListChecks size={16} className="text-emerald-600" />
|
| 269 |
-
<h3 className="text-[10px] font-black text-emerald-700 uppercase tracking-[0.2em]">
|
| 270 |
-
Troubleshooting Plan
|
| 271 |
-
</h3>
|
| 272 |
-
</div>
|
| 273 |
-
<div className="space-y-2">
|
| 274 |
-
{steps.map((step) => (
|
| 275 |
-
<button
|
| 276 |
-
key={step.id}
|
| 277 |
-
onClick={() => toggleStep(step.id)}
|
| 278 |
-
className={`w-full text-left flex items-center gap-3 p-3 rounded-xl border transition-all duration-300 ${
|
| 279 |
-
step.completed
|
| 280 |
-
? 'bg-emerald-100 border-emerald-200 opacity-60'
|
| 281 |
-
: 'bg-white border-slate-100 hover:border-emerald-200 hover:bg-emerald-50/50'
|
| 282 |
-
}`}
|
| 283 |
-
>
|
| 284 |
-
<div className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 transition-all duration-300 ${
|
| 285 |
-
step.completed
|
| 286 |
-
? 'bg-emerald-500 text-white'
|
| 287 |
-
: 'bg-slate-100 text-slate-400'
|
| 288 |
-
}`}>
|
| 289 |
-
{step.completed ? (
|
| 290 |
-
<CheckCircle2 size={14} />
|
| 291 |
-
) : (
|
| 292 |
-
<span className="text-[10px] font-black">{step.id}</span>
|
| 293 |
-
)}
|
| 294 |
-
</div>
|
| 295 |
-
<span className={`text-[13px] font-bold leading-snug transition-all duration-300 ${
|
| 296 |
-
step.completed ? 'text-slate-400 line-through' : 'text-slate-700'
|
| 297 |
-
}`}>
|
| 298 |
-
{step.task}
|
| 299 |
-
</span>
|
| 300 |
-
</button>
|
| 301 |
-
))}
|
| 302 |
-
</div>
|
| 303 |
-
</div>
|
| 304 |
-
)}
|
| 305 |
-
|
| 306 |
{/* Chat Messages */}
|
| 307 |
<div
|
| 308 |
ref={scrollRef}
|
|
|
|
| 19 |
const { aiTicket } = useTicketStore();
|
| 20 |
const navigate = useNavigate();
|
| 21 |
const [messages, setMessages] = useState([]);
|
|
|
|
| 22 |
const [isThinking, setIsThinking] = useState(false);
|
| 23 |
const [isFinal, setIsFinal] = useState(false);
|
| 24 |
const [inputText, setInputText] = useState('');
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
if (newSteps.length >= 2) {
|
| 80 |
+
// Steps parsed successfully; welcome message sent below
|
| 81 |
} else {
|
| 82 |
const sentences = response
|
| 83 |
.replace(/\*\*/g, '')
|
|
|
|
| 87 |
.slice(0, 4);
|
| 88 |
|
| 89 |
if (sentences.length >= 2) {
|
| 90 |
+
// Plan parsed but not rendered; welcome message sent below
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
} else {
|
| 92 |
throw new Error("Could not parse steps from AI response.");
|
| 93 |
}
|
|
|
|
| 197 |
else recognition.start();
|
| 198 |
};
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
if (!aiTicket) return null;
|
| 201 |
|
| 202 |
return (
|
|
|
|
| 249 |
</div>
|
| 250 |
</div>
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
{/* Chat Messages */}
|
| 253 |
<div
|
| 254 |
ref={scrollRef}
|
Frontend/src/user/pages/CreateTicket.jsx
CHANGED
|
@@ -21,6 +21,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|
| 21 |
import { Button } from "../../components/ui/button";
|
| 22 |
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../components/ui/card";
|
| 23 |
import { Textarea } from "../../components/ui/textarea";
|
|
|
|
| 24 |
import { translateText, SUPPORTED_LANGUAGES } from '../../services/translationService';
|
| 25 |
import TemplateSelector from '../components/TemplateSelector';
|
| 26 |
import TemplateForm from '../components/TemplateForm';
|
|
@@ -110,7 +111,6 @@ const CreateTicket = () => {
|
|
| 110 |
const processOCR = async (imageFile) => {
|
| 111 |
setIsOcrLoading(true);
|
| 112 |
try {
|
| 113 |
-
const { default: Tesseract } = await import('tesseract.js');
|
| 114 |
const { data: { text } } = await Tesseract.recognize(imageFile, 'eng');
|
| 115 |
setExtractedOCR(text.trim());
|
| 116 |
} catch (err) {
|
|
|
|
| 21 |
import { Button } from "../../components/ui/button";
|
| 22 |
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../components/ui/card";
|
| 23 |
import { Textarea } from "../../components/ui/textarea";
|
| 24 |
+
import Tesseract from 'tesseract.js';
|
| 25 |
import { translateText, SUPPORTED_LANGUAGES } from '../../services/translationService';
|
| 26 |
import TemplateSelector from '../components/TemplateSelector';
|
| 27 |
import TemplateForm from '../components/TemplateForm';
|
|
|
|
| 111 |
const processOCR = async (imageFile) => {
|
| 112 |
setIsOcrLoading(true);
|
| 113 |
try {
|
|
|
|
| 114 |
const { data: { text } } = await Tesseract.recognize(imageFile, 'eng');
|
| 115 |
setExtractedOCR(text.trim());
|
| 116 |
} catch (err) {
|
Frontend/src/user/pages/MyTickets.jsx
CHANGED
|
@@ -120,14 +120,6 @@ function MyTickets() {
|
|
| 120 |
return 'text-gray-600';
|
| 121 |
};
|
| 122 |
|
| 123 |
-
const getTranslationInfo = (ticket) => {
|
| 124 |
-
const t = ticket?.metadata?.translation;
|
| 125 |
-
if (!t?.translated) return null;
|
| 126 |
-
return {
|
| 127 |
-
sourceLanguageName: t.source_language_name || t.source_language || 'Unknown',
|
| 128 |
-
};
|
| 129 |
-
};
|
| 130 |
-
|
| 131 |
return (
|
| 132 |
<main className="flex-1 max-w-[1200px] w-full mx-auto px-6 py-10 flex flex-col gap-8">
|
| 133 |
{/* Header section */}
|
|
@@ -325,11 +317,6 @@ function MyTickets() {
|
|
| 325 |
<p className="text-sm font-semibold text-gray-900 truncate group-hover:text-emerald-700 transition-colors">
|
| 326 |
{ticket.summary || ticket.subject || ticket.description || "No subject"}
|
| 327 |
</p>
|
| 328 |
-
{getTranslationInfo(ticket) && (
|
| 329 |
-
<p className="text-[10px] text-slate-500 mt-1">
|
| 330 |
-
Translated from {getTranslationInfo(ticket).sourceLanguageName}
|
| 331 |
-
</p>
|
| 332 |
-
)}
|
| 333 |
</td>
|
| 334 |
<td className="px-6 py-4">
|
| 335 |
<span className="text-sm font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-md">
|
|
|
|
| 120 |
return 'text-gray-600';
|
| 121 |
};
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
return (
|
| 124 |
<main className="flex-1 max-w-[1200px] w-full mx-auto px-6 py-10 flex flex-col gap-8">
|
| 125 |
{/* Header section */}
|
|
|
|
| 317 |
<p className="text-sm font-semibold text-gray-900 truncate group-hover:text-emerald-700 transition-colors">
|
| 318 |
{ticket.summary || ticket.subject || ticket.description || "No subject"}
|
| 319 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
</td>
|
| 321 |
<td className="px-6 py-4">
|
| 322 |
<span className="text-sm font-medium text-gray-600 bg-gray-100 px-2.5 py-1 rounded-md">
|
Frontend/src/user/pages/TicketDetail.jsx
CHANGED
|
@@ -22,7 +22,6 @@ const TicketDetail = () => {
|
|
| 22 |
const [isReopening, setIsReopening] = useState(false);
|
| 23 |
const [showCsat, setShowCsat] = useState(false);
|
| 24 |
const [csatHasBeenDismissed, setCsatHasBeenDismissed] = useState(false);
|
| 25 |
-
const [showOriginalText, setShowOriginalText] = useState(false);
|
| 26 |
|
| 27 |
useEffect(() => {
|
| 28 |
window.scrollTo(0, 0);
|
|
@@ -123,10 +122,6 @@ const TicketDetail = () => {
|
|
| 123 |
const solutionSteps = Array.isArray(ticket.solution_steps) ? ticket.solution_steps : [];
|
| 124 |
const isAutoResolved = ticket.auto_resolve === true;
|
| 125 |
const confidenceScore = ticket.metadata?.confidence ?? ticket.routing_confidence ?? 0.92;
|
| 126 |
-
const translationMeta = ticket.metadata?.translation;
|
| 127 |
-
const originalTextMeta = ticket.metadata?.original_text;
|
| 128 |
-
const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
|
| 129 |
-
const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
|
| 130 |
|
| 131 |
|
| 132 |
const handleReopen = async () => {
|
|
@@ -189,27 +184,6 @@ const TicketDetail = () => {
|
|
| 189 |
|
| 190 |
{/* LEFT SIDE (Main Content) */}
|
| 191 |
<div className="lg:col-span-2 flex flex-col gap-6">
|
| 192 |
-
{isTranslated && (
|
| 193 |
-
<Card className="p-4 rounded-2xl border border-sky-100 bg-sky-50/70 shadow-sm">
|
| 194 |
-
<div className="flex items-center justify-between gap-3">
|
| 195 |
-
<p className="text-sm font-semibold text-sky-900">
|
| 196 |
-
Translated from {sourceLanguageName}
|
| 197 |
-
</p>
|
| 198 |
-
<button
|
| 199 |
-
type="button"
|
| 200 |
-
onClick={() => setShowOriginalText(prev => !prev)}
|
| 201 |
-
className="text-xs font-bold text-sky-700 hover:text-sky-900"
|
| 202 |
-
>
|
| 203 |
-
{showOriginalText ? "View English" : "View Original"}
|
| 204 |
-
</button>
|
| 205 |
-
</div>
|
| 206 |
-
{showOriginalText && (
|
| 207 |
-
<p className="mt-3 text-sm text-slate-700 bg-white border border-sky-100 rounded-lg px-3 py-2">
|
| 208 |
-
{originalTextMeta?.description}
|
| 209 |
-
</p>
|
| 210 |
-
)}
|
| 211 |
-
</Card>
|
| 212 |
-
)}
|
| 213 |
|
| 214 |
{/* Card 1: Ticket Timeline */}
|
| 215 |
<Card className="p-6 sm:p-8 rounded-2xl border border-gray-100 shadow-sm bg-white">
|
|
|
|
| 22 |
const [isReopening, setIsReopening] = useState(false);
|
| 23 |
const [showCsat, setShowCsat] = useState(false);
|
| 24 |
const [csatHasBeenDismissed, setCsatHasBeenDismissed] = useState(false);
|
|
|
|
| 25 |
|
| 26 |
useEffect(() => {
|
| 27 |
window.scrollTo(0, 0);
|
|
|
|
| 122 |
const solutionSteps = Array.isArray(ticket.solution_steps) ? ticket.solution_steps : [];
|
| 123 |
const isAutoResolved = ticket.auto_resolve === true;
|
| 124 |
const confidenceScore = ticket.metadata?.confidence ?? ticket.routing_confidence ?? 0.92;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
const handleReopen = async () => {
|
|
|
|
| 184 |
|
| 185 |
{/* LEFT SIDE (Main Content) */}
|
| 186 |
<div className="lg:col-span-2 flex flex-col gap-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
{/* Card 1: Ticket Timeline */}
|
| 189 |
<Card className="p-6 sm:p-8 rounded-2xl border border-gray-100 shadow-sm bg-white">
|
Frontend/vite.config.js
CHANGED
|
@@ -15,6 +15,6 @@ export default defineConfig({
|
|
| 15 |
},
|
| 16 |
},
|
| 17 |
build: {
|
| 18 |
-
sourcemap:
|
| 19 |
}
|
| 20 |
})
|
|
|
|
| 15 |
},
|
| 16 |
},
|
| 17 |
build: {
|
| 18 |
+
sourcemap: true
|
| 19 |
}
|
| 20 |
})
|
MobileApp/App.js
CHANGED
|
@@ -127,55 +127,17 @@ const AppContent = () => {
|
|
| 127 |
const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
|
| 128 |
|
| 129 |
useEffect(() => {
|
| 130 |
-
let finished = false;
|
| 131 |
-
|
| 132 |
const initialize = async () => {
|
| 133 |
-
// Safety timeout wrapper: if anything hangs, force mount after 3 seconds
|
| 134 |
-
const timeoutId = setTimeout(async () => {
|
| 135 |
-
if (!finished) {
|
| 136 |
-
console.log('[AuthInit] Timeout reached (3s). Forcing mount fallback.');
|
| 137 |
-
try {
|
| 138 |
-
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 139 |
-
setShowOnboarding(onboardingDone === null);
|
| 140 |
-
} catch (err) {
|
| 141 |
-
setShowOnboarding(false);
|
| 142 |
-
}
|
| 143 |
-
setLoading(false);
|
| 144 |
-
}
|
| 145 |
-
}, 3000);
|
| 146 |
-
|
| 147 |
try {
|
| 148 |
-
|
| 149 |
-
const [cachedStatus, cachedRole] = await Promise.all([
|
| 150 |
-
AsyncStorage.getItem('@user_status'),
|
| 151 |
-
AsyncStorage.getItem('@user_role'),
|
| 152 |
-
]);
|
| 153 |
-
|
| 154 |
-
if (cachedStatus) setUserStatus(cachedStatus);
|
| 155 |
-
if (cachedRole) setUserRole(cachedRole);
|
| 156 |
-
|
| 157 |
-
// 2. Fetch session with a 2.5 second timeout wrapper to prevent slow refreshing from locking the app
|
| 158 |
-
const sessionPromise = supabase.auth.getSession();
|
| 159 |
-
const sessionTimeoutPromise = new Promise((_, reject) =>
|
| 160 |
-
setTimeout(() => reject(new Error('Session fetch timed out')), 2500)
|
| 161 |
-
);
|
| 162 |
-
|
| 163 |
-
const { data: { session } } = await Promise.race([sessionPromise, sessionTimeoutPromise]);
|
| 164 |
setSession(session);
|
| 165 |
|
| 166 |
-
// 3. If session is valid, validate/fetch the user profile
|
| 167 |
if (session?.user) {
|
| 168 |
-
const
|
| 169 |
.from('profiles')
|
| 170 |
.select('status, role')
|
| 171 |
.eq('id', session.user.id)
|
| 172 |
.single();
|
| 173 |
-
|
| 174 |
-
const profileTimeoutPromise = new Promise((_, reject) =>
|
| 175 |
-
setTimeout(() => reject(new Error('Profile fetch timed out')), 2000)
|
| 176 |
-
);
|
| 177 |
-
|
| 178 |
-
const { data, error } = await Promise.race([profilePromise, profileTimeoutPromise]);
|
| 179 |
|
| 180 |
if (error) {
|
| 181 |
console.log('[AuthInit] Profile fetch error, validating session:', error.message);
|
|
@@ -184,29 +146,20 @@ const AppContent = () => {
|
|
| 184 |
if (userError) {
|
| 185 |
console.log('[AuthInit] Token validation failed. Clearing session.');
|
| 186 |
setSession(null);
|
| 187 |
-
await AsyncStorage.removeItem('@user_status');
|
| 188 |
-
await AsyncStorage.removeItem('@user_role');
|
| 189 |
} else {
|
| 190 |
-
// Valid token but profiles table is offline;
|
| 191 |
-
|
| 192 |
-
|
| 193 |
}
|
| 194 |
} else {
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
setUserStatus(status);
|
| 198 |
-
setUserRole(role);
|
| 199 |
-
await Promise.all([
|
| 200 |
-
AsyncStorage.setItem('@user_status', status),
|
| 201 |
-
AsyncStorage.setItem('@user_role', role),
|
| 202 |
-
]);
|
| 203 |
}
|
| 204 |
}
|
| 205 |
} catch (e) {
|
| 206 |
-
console.log('[AuthInit]
|
| 207 |
} finally {
|
| 208 |
-
|
| 209 |
-
clearTimeout(timeoutId);
|
| 210 |
try {
|
| 211 |
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 212 |
setShowOnboarding(onboardingDone === null);
|
|
@@ -231,17 +184,12 @@ const AppContent = () => {
|
|
| 231 |
|
| 232 |
if (error) {
|
| 233 |
console.log('[AuthChange] Profile query failed:', error.message);
|
|
|
|
| 234 |
setUserStatus('active');
|
| 235 |
setUserRole('user');
|
| 236 |
} else {
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
setUserStatus(status);
|
| 240 |
-
setUserRole(role);
|
| 241 |
-
await Promise.all([
|
| 242 |
-
AsyncStorage.setItem('@user_status', status),
|
| 243 |
-
AsyncStorage.setItem('@user_role', role),
|
| 244 |
-
]);
|
| 245 |
}
|
| 246 |
} catch (err) {
|
| 247 |
console.warn('[AuthChange] Uncaught exception inside handler:', err);
|
|
@@ -251,10 +199,6 @@ const AppContent = () => {
|
|
| 251 |
} else {
|
| 252 |
setUserStatus(null);
|
| 253 |
setUserRole('user');
|
| 254 |
-
await Promise.all([
|
| 255 |
-
AsyncStorage.removeItem('@user_status'),
|
| 256 |
-
AsyncStorage.removeItem('@user_role'),
|
| 257 |
-
]);
|
| 258 |
}
|
| 259 |
});
|
| 260 |
|
|
@@ -272,15 +216,9 @@ const AppContent = () => {
|
|
| 272 |
schema: 'public',
|
| 273 |
table: 'profiles',
|
| 274 |
filter: `id=eq.${session.user.id}`,
|
| 275 |
-
},
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
setUserStatus(status);
|
| 279 |
-
setUserRole(role);
|
| 280 |
-
await Promise.all([
|
| 281 |
-
AsyncStorage.setItem('@user_status', status),
|
| 282 |
-
AsyncStorage.setItem('@user_role', role),
|
| 283 |
-
]);
|
| 284 |
})
|
| 285 |
.subscribe();
|
| 286 |
|
|
@@ -291,6 +229,7 @@ const AppContent = () => {
|
|
| 291 |
useEffect(() => {
|
| 292 |
const handleUrl = async ({ url }) => {
|
| 293 |
if (!url) return;
|
|
|
|
| 294 |
const hashIndex = url.indexOf('#');
|
| 295 |
if (hashIndex === -1) return;
|
| 296 |
const hash = url.substring(hashIndex + 1);
|
|
@@ -316,7 +255,9 @@ const AppContent = () => {
|
|
| 316 |
}
|
| 317 |
};
|
| 318 |
|
|
|
|
| 319 |
const subscription = Linking.addEventListener('url', handleUrl);
|
|
|
|
| 320 |
Linking.getInitialURL().then(url => url && handleUrl({ url }));
|
| 321 |
|
| 322 |
return () => subscription.remove();
|
|
|
|
| 127 |
const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
|
| 128 |
|
| 129 |
useEffect(() => {
|
|
|
|
|
|
|
| 130 |
const initialize = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
try {
|
| 132 |
+
const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
setSession(session);
|
| 134 |
|
|
|
|
| 135 |
if (session?.user) {
|
| 136 |
+
const { data, error } = await supabase
|
| 137 |
.from('profiles')
|
| 138 |
.select('status, role')
|
| 139 |
.eq('id', session.user.id)
|
| 140 |
.single();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
if (error) {
|
| 143 |
console.log('[AuthInit] Profile fetch error, validating session:', error.message);
|
|
|
|
| 146 |
if (userError) {
|
| 147 |
console.log('[AuthInit] Token validation failed. Clearing session.');
|
| 148 |
setSession(null);
|
|
|
|
|
|
|
| 149 |
} else {
|
| 150 |
+
// Valid token but profiles table is temporarily offline; default to user
|
| 151 |
+
setUserStatus('active');
|
| 152 |
+
setUserRole('user');
|
| 153 |
}
|
| 154 |
} else {
|
| 155 |
+
setUserStatus(data?.status || 'active');
|
| 156 |
+
setUserRole(data?.role || 'user');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
}
|
| 159 |
} catch (e) {
|
| 160 |
+
console.log('[AuthInit] Crash caught during initialization:', e);
|
| 161 |
} finally {
|
| 162 |
+
// Guarantee showOnboarding is resolved to a boolean to prevent React Navigation stack layout mismatch
|
|
|
|
| 163 |
try {
|
| 164 |
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 165 |
setShowOnboarding(onboardingDone === null);
|
|
|
|
| 184 |
|
| 185 |
if (error) {
|
| 186 |
console.log('[AuthChange] Profile query failed:', error.message);
|
| 187 |
+
// Default to safe values to avoid blank screens
|
| 188 |
setUserStatus('active');
|
| 189 |
setUserRole('user');
|
| 190 |
} else {
|
| 191 |
+
setUserStatus(data?.status || 'active');
|
| 192 |
+
setUserRole(data?.role || 'user');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
}
|
| 194 |
} catch (err) {
|
| 195 |
console.warn('[AuthChange] Uncaught exception inside handler:', err);
|
|
|
|
| 199 |
} else {
|
| 200 |
setUserStatus(null);
|
| 201 |
setUserRole('user');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
});
|
| 204 |
|
|
|
|
| 216 |
schema: 'public',
|
| 217 |
table: 'profiles',
|
| 218 |
filter: `id=eq.${session.user.id}`,
|
| 219 |
+
}, (payload) => {
|
| 220 |
+
setUserStatus(payload.new.status);
|
| 221 |
+
setUserRole(payload.new.role || 'user');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
})
|
| 223 |
.subscribe();
|
| 224 |
|
|
|
|
| 229 |
useEffect(() => {
|
| 230 |
const handleUrl = async ({ url }) => {
|
| 231 |
if (!url) return;
|
| 232 |
+
// Parse hash fragment: helpdeskai://login#access_token=...&refresh_token=...
|
| 233 |
const hashIndex = url.indexOf('#');
|
| 234 |
if (hashIndex === -1) return;
|
| 235 |
const hash = url.substring(hashIndex + 1);
|
|
|
|
| 255 |
}
|
| 256 |
};
|
| 257 |
|
| 258 |
+
// Handle app already open
|
| 259 |
const subscription = Linking.addEventListener('url', handleUrl);
|
| 260 |
+
// Handle cold start — app launched from the link
|
| 261 |
Linking.getInitialURL().then(url => url && handleUrl({ url }));
|
| 262 |
|
| 263 |
return () => subscription.remove();
|
MobileApp/package-lock.json
CHANGED
|
@@ -31,7 +31,7 @@
|
|
| 31 |
"react-native-screens": "~4.16.0",
|
| 32 |
"react-native-svg": "15.12.1",
|
| 33 |
"react-native-url-polyfill": "^3.0.0",
|
| 34 |
-
"react-native-webview": "^13.
|
| 35 |
"zustand": "^5.0.12"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
|
|
|
| 31 |
"react-native-screens": "~4.16.0",
|
| 32 |
"react-native-svg": "15.12.1",
|
| 33 |
"react-native-url-polyfill": "^3.0.0",
|
| 34 |
+
"react-native-webview": "^13.16.1",
|
| 35 |
"zustand": "^5.0.12"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
MobileApp/package.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
| 32 |
"react-native-screens": "~4.16.0",
|
| 33 |
"react-native-svg": "15.12.1",
|
| 34 |
"react-native-url-polyfill": "^3.0.0",
|
| 35 |
-
"react-native-webview": "^13.
|
| 36 |
"zustand": "^5.0.12"
|
| 37 |
},
|
| 38 |
"private": true,
|
|
|
|
| 32 |
"react-native-screens": "~4.16.0",
|
| 33 |
"react-native-svg": "15.12.1",
|
| 34 |
"react-native-url-polyfill": "^3.0.0",
|
| 35 |
+
"react-native-webview": "^13.16.1",
|
| 36 |
"zustand": "^5.0.12"
|
| 37 |
},
|
| 38 |
"private": true,
|
MobileApp/src/lib/supabase.js
CHANGED
|
@@ -2,14 +2,8 @@ import 'react-native-url-polyfill/auto';
|
|
| 2 |
import { createClient } from '@supabase/supabase-js';
|
| 3 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 4 |
|
| 5 |
-
const supabaseUrl =
|
| 6 |
-
const supabaseKey =
|
| 7 |
-
|
| 8 |
-
if (!supabaseUrl || !supabaseKey) {
|
| 9 |
-
throw new Error(
|
| 10 |
-
'Missing Supabase mobile config. Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY in MobileApp/.env or EAS secrets.'
|
| 11 |
-
);
|
| 12 |
-
}
|
| 13 |
|
| 14 |
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
| 15 |
auth: {
|
|
|
|
| 2 |
import { createClient } from '@supabase/supabase-js';
|
| 3 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 4 |
|
| 5 |
+
const supabaseUrl = 'https://aejuenhqciagpntcqoir.supabase.co';
|
| 6 |
+
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFlanVlbmhxY2lhZ3BudGNxb2lyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIzODQwNzgsImV4cCI6MjA4Nzk2MDA3OH0.-OxgEW5t4alPGlzV_JZDRZcLsQbbMap6jiWjfAVkMMY';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
| 9 |
auth: {
|
MobileApp/src/screens/auth/LoginScreen.js
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
| 5 |
ScrollView, StatusBar, Animated,
|
| 6 |
} from 'react-native';
|
| 7 |
import { supabase } from '../../lib/supabase';
|
| 8 |
-
import { backendLogin, backendLogout } from '../../lib/authBackend';
|
| 9 |
import { COLORS, SHADOWS } from '../../styles/theme';
|
| 10 |
import { Lock, Mail, Eye, EyeOff, Zap, ArrowRight, ShieldCheck } from 'lucide-react-native';
|
| 11 |
import { LinearGradient } from 'expo-linear-gradient';
|
|
@@ -53,7 +52,6 @@ const LoginScreen = () => {
|
|
| 53 |
}
|
| 54 |
setLoading(true);
|
| 55 |
try {
|
| 56 |
-
await backendLogin(email, password);
|
| 57 |
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
| 58 |
if (error) throw error;
|
| 59 |
|
|
|
|
| 5 |
ScrollView, StatusBar, Animated,
|
| 6 |
} from 'react-native';
|
| 7 |
import { supabase } from '../../lib/supabase';
|
|
|
|
| 8 |
import { COLORS, SHADOWS } from '../../styles/theme';
|
| 9 |
import { Lock, Mail, Eye, EyeOff, Zap, ArrowRight, ShieldCheck } from 'lucide-react-native';
|
| 10 |
import { LinearGradient } from 'expo-linear-gradient';
|
|
|
|
| 52 |
}
|
| 53 |
setLoading(true);
|
| 54 |
try {
|
|
|
|
| 55 |
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
| 56 |
if (error) throw error;
|
| 57 |
|
MobileApp/src/screens/user/ProfileScreen.js
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
| 5 |
} from 'react-native';
|
| 6 |
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 7 |
import { supabase } from '../../lib/supabase';
|
| 8 |
-
import { backendLogout } from '../../lib/authBackend';
|
| 9 |
import { COLORS, SHADOWS } from '../../styles/theme';
|
| 10 |
import {
|
| 11 |
User, Mail, Building2, ShieldCheck, Calendar, Ticket, Zap,
|
|
@@ -227,7 +226,6 @@ const ProfileScreen = () => {
|
|
| 227 |
const handleLogout = async () => {
|
| 228 |
try {
|
| 229 |
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
| 230 |
-
await backendLogout();
|
| 231 |
// Forcefully wipe all Supabase session keys from AsyncStorage first to trigger instant navigation resetting
|
| 232 |
const keys = await AsyncStorage.getAllKeys();
|
| 233 |
const supabaseKeys = keys.filter(k => k.startsWith('sb-') || k.includes('supabase'));
|
|
@@ -238,7 +236,6 @@ const ProfileScreen = () => {
|
|
| 238 |
} catch (e) {
|
| 239 |
console.warn("Logout error, forcing full wipe:", e);
|
| 240 |
await AsyncStorage.clear();
|
| 241 |
-
await supabase.auth.signOut();
|
| 242 |
}
|
| 243 |
};
|
| 244 |
|
|
|
|
| 5 |
} from 'react-native';
|
| 6 |
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 7 |
import { supabase } from '../../lib/supabase';
|
|
|
|
| 8 |
import { COLORS, SHADOWS } from '../../styles/theme';
|
| 9 |
import {
|
| 10 |
User, Mail, Building2, ShieldCheck, Calendar, Ticket, Zap,
|
|
|
|
| 226 |
const handleLogout = async () => {
|
| 227 |
try {
|
| 228 |
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
|
|
| 229 |
// Forcefully wipe all Supabase session keys from AsyncStorage first to trigger instant navigation resetting
|
| 230 |
const keys = await AsyncStorage.getAllKeys();
|
| 231 |
const supabaseKeys = keys.filter(k => k.startsWith('sb-') || k.includes('supabase'));
|
|
|
|
| 236 |
} catch (e) {
|
| 237 |
console.warn("Logout error, forcing full wipe:", e);
|
| 238 |
await AsyncStorage.clear();
|
|
|
|
| 239 |
}
|
| 240 |
};
|
| 241 |
|
README.md
CHANGED
|
@@ -58,9 +58,7 @@ pinned: false
|
|
| 58 |
|
| 59 |
## 🌟 GSSoC '26 Contributor & Community Campaign
|
| 60 |
|
| 61 |
-
We are extremely proud to be part of the **GirlScript Summer of Code (GSSoC) 2026**! To
|
| 62 |
-
|
| 63 |
-
To support the project and get real-time open-source project updates, please make sure you participate in our community campaign:
|
| 64 |
|
| 65 |
<div align="center">
|
| 66 |
|
|
|
|
| 58 |
|
| 59 |
## 🌟 GSSoC '26 Contributor & Community Campaign
|
| 60 |
|
| 61 |
+
We are extremely proud to be part of the **GirlScript Summer of Code (GSSoC) 2026**! To support the project and get real-time open-source project updates, please make sure you participate in our community campaign:
|
|
|
|
|
|
|
| 62 |
|
| 63 |
<div align="center">
|
| 64 |
|
backend/.env.example
CHANGED
|
@@ -1,106 +1,18 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 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 |
-
#
|
| 40 |
-
#
|
| 41 |
-
#
|
| 42 |
-
# duplicate detection. If unset, the services will attempt to download the
|
| 43 |
-
# default model on first run (requires internet access).
|
| 44 |
SENTENCE_TRANSFORMER_MODEL_PATH=
|
| 45 |
|
| 46 |
-
|
| 47 |
-
#
|
| 48 |
-
# Startup / Health-Check Behaviour
|
| 49 |
-
# -----------------------------------------------------------------------------
|
| 50 |
-
# Controls strict startup validation. When `0` (default), the backend will
|
| 51 |
-
# REFUSE to start if core classifier assets are missing — this prevents the
|
| 52 |
-
# silent "Unknown" classification fallback documented in ISSUE_DEBUG_FINDINGS.
|
| 53 |
-
# Set to `1` only for local development without the full model bundle.
|
| 54 |
-
ALLOW_DEGRADED_STARTUP=0
|
| 55 |
-
|
| 56 |
-
# When `true`, startup will hard-fail if Supabase credentials are missing.
|
| 57 |
-
# Default `false` allows the backend to run in "no-persistence" mode for tests.
|
| 58 |
REQUIRE_SUPABASE=false
|
| 59 |
-
|
| 60 |
-
# Slack Alerts
|
| 61 |
-
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
|
| 62 |
-
|
| 63 |
-
# -----------------------------------------------------------------------------
|
| 64 |
-
# Health-Check Probe (Docker / Kubernetes)
|
| 65 |
-
# -----------------------------------------------------------------------------
|
| 66 |
-
HEALTHCHECK_URL=http://127.0.0.1:7860/ready
|
| 67 |
-
HEALTHCHECK_TIMEOUT_SECONDS=3
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
# -----------------------------------------------------------------------------
|
| 71 |
-
# SLA Escalation Background Worker
|
| 72 |
-
# -----------------------------------------------------------------------------
|
| 73 |
-
SLA_ESCALATION_ENABLED=true
|
| 74 |
-
SLA_ESCALATION_INTERVAL_SECONDS=300
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
# -----------------------------------------------------------------------------
|
| 78 |
-
# Auto-Close Cron Job (Issue #41)
|
| 79 |
-
# -----------------------------------------------------------------------------
|
| 80 |
-
AUTO_CLOSE_ENABLED=true
|
| 81 |
-
AUTO_CLOSE_DAYS=7
|
| 82 |
-
# Standard 5-field cron expression. Default: 02:00 UTC every day.
|
| 83 |
-
AUTO_CLOSE_CRON_SCHEDULE=0 2 * * *
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
# -----------------------------------------------------------------------------
|
| 87 |
-
# Notification Routing Middleware (Issue #41)
|
| 88 |
-
# -----------------------------------------------------------------------------
|
| 89 |
-
# Accepted values: debug, info, warning
|
| 90 |
-
NOTIFICATION_ROUTING_LOG_LEVEL=info
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
# -----------------------------------------------------------------------------
|
| 94 |
-
# Generic Environment Flag
|
| 95 |
-
# -----------------------------------------------------------------------------
|
| 96 |
-
# Set to `development` to enable extra debug logging in some services.
|
| 97 |
-
ENV=production
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
# -----------------------------------------------------------------------------
|
| 101 |
-
# Redis Inference Cache (Issue #131)
|
| 102 |
-
# -----------------------------------------------------------------------------
|
| 103 |
-
# When true, cache DistilBERT classifications and sentence-transformer embeddings.
|
| 104 |
-
USE_REDIS_CACHE=false
|
| 105 |
-
REDIS_URL=redis://127.0.0.1:6379/0
|
| 106 |
-
REDIS_CACHE_TTL_SECONDS=3600
|
|
|
|
| 1 |
+
# Supabase Configuration
|
| 2 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 3 |
+
SUPABASE_SERVICE_KEY=your-service-key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
# Startup Mode
|
| 6 |
+
# Set ALLOW_DEGRADED_STARTUP=1 to allow backend startup even if duplicate/RAG models fail to load
|
| 7 |
+
# Useful for offline/dev environments where model downloads may not be available
|
| 8 |
+
ALLOW_DEGRADED_STARTUP=0
|
| 9 |
|
| 10 |
+
# Sentence Transformers Model Path
|
| 11 |
+
# Provide a local path to the sentence-transformers model to avoid downloading from HuggingFace
|
| 12 |
+
# If not set, model will be downloaded from HuggingFace (requires internet)
|
| 13 |
+
# Example: ./models/all-MiniLM-L6-v2 or /opt/models/all-MiniLM-L6-v2
|
|
|
|
|
|
|
| 14 |
SENTENCE_TRANSFORMER_MODEL_PATH=
|
| 15 |
|
| 16 |
+
# Readiness Check
|
| 17 |
+
# Set REQUIRE_SUPABASE=true to include Supabase configuration in the strict readiness gate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
REQUIRE_SUPABASE=false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/Dockerfile
CHANGED
|
@@ -1,40 +1,36 @@
|
|
| 1 |
-
#
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 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 |
-
|
| 30 |
-
|
| 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 |
-
|
| 39 |
-
|
| 40 |
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
|
| 4 |
+
LABEL version="1.1.1" rebuild_trigger="2026-03-08-2032"
|
| 5 |
|
| 6 |
+
# Set the working directory to /app
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Install system dependencies required for EasyOCR and OpenCV
|
| 10 |
+
RUN apt-get update && apt-get install -y \
|
| 11 |
+
libgl1 \
|
| 12 |
+
libglib2.0-0 \
|
| 13 |
git \
|
| 14 |
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
|
| 16 |
+
# Copy the requirements file into the container
|
| 17 |
COPY requirements.txt .
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
# Install dependencies (no-cache-dir keeps the docker image smaller)
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
|
| 22 |
+
# Copy all the remaining files into the container as a 'backend' directory
|
| 23 |
+
# This allows absolute imports like 'from backend.services...' to work perfectly
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
COPY . /app/backend
|
| 25 |
|
| 26 |
+
# Tell Python where to look for modules (so it can find the 'backend' folder)
|
| 27 |
+
ENV PYTHONPATH=/app
|
|
|
|
| 28 |
|
| 29 |
+
# Expose port 7860 (Hugging Face Spaces default)
|
| 30 |
EXPOSE 7860
|
| 31 |
|
| 32 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
| 33 |
CMD ["python", "backend/healthcheck.py"]
|
| 34 |
|
| 35 |
+
# Run the FastAPI server via Uvicorn
|
|
|
|
| 36 |
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
backend/auth/crypto.py
CHANGED
|
@@ -1,218 +1,98 @@
|
|
| 1 |
import os
|
| 2 |
-
import base64
|
| 3 |
-
import logging
|
| 4 |
import hashlib
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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
|
| 88 |
nonce = os.urandom(12)
|
| 89 |
-
|
| 90 |
-
#
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
payload = nonce + ciphertext
|
| 94 |
-
encoded = base64.b64encode(payload).decode('utf-8')
|
| 95 |
-
return f"{PREFIX}{encoded}"
|
| 96 |
except Exception as e:
|
| 97 |
-
|
| 98 |
-
return
|
| 99 |
|
| 100 |
-
def
|
| 101 |
-
"""Decrypt
|
| 102 |
-
if not
|
| 103 |
-
return
|
| 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 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
return decrypted_bytes.decode('utf-8')
|
| 119 |
except Exception as e:
|
| 120 |
-
|
| 121 |
-
return
|
| 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 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
return WrappedRequestBuilder(res, self._table_name)
|
| 169 |
-
|
| 170 |
-
def update(self, json: Any, *args, **kwargs) -> "WrappedRequestBuilder":
|
| 171 |
-
if self._table_name == "tickets":
|
| 172 |
-
json = encrypt_payload(json)
|
| 173 |
-
res = self._builder.update(json, *args, **kwargs)
|
| 174 |
-
return WrappedRequestBuilder(res, self._table_name)
|
| 175 |
-
|
| 176 |
-
def execute(self, *args, **kwargs) -> Any:
|
| 177 |
-
res = self._builder.execute(*args, **kwargs)
|
| 178 |
-
if self._table_name == "tickets" and res and hasattr(res, "data"):
|
| 179 |
-
res.data = decrypt_payload(res.data)
|
| 180 |
-
return res
|
| 181 |
-
|
| 182 |
-
def __getattr__(self, name: str) -> Any:
|
| 183 |
-
attr = getattr(self._builder, name)
|
| 184 |
-
if callable(attr):
|
| 185 |
-
def wrapper(*args, **kwargs):
|
| 186 |
-
res = attr(*args, **kwargs)
|
| 187 |
-
if res is self._builder:
|
| 188 |
-
return self
|
| 189 |
-
if hasattr(res, "execute") or hasattr(res, "table") or hasattr(res, "insert"):
|
| 190 |
-
return WrappedRequestBuilder(res, self._table_name)
|
| 191 |
-
return res
|
| 192 |
-
return wrapper
|
| 193 |
-
return attr
|
| 194 |
-
|
| 195 |
-
def __setattr__(self, name: str, value: Any) -> None:
|
| 196 |
-
setattr(self._builder, name, value)
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
def wrap_client(client: Any) -> Any:
|
| 200 |
-
"""Wraps a Supabase client's table method for transparent tickets encryption."""
|
| 201 |
-
if client is None:
|
| 202 |
-
return None
|
| 203 |
-
|
| 204 |
-
# Avoid double wrapping
|
| 205 |
-
if hasattr(client, "_wrapped_by_crypto"):
|
| 206 |
-
return client
|
| 207 |
-
|
| 208 |
-
original_table = client.table
|
| 209 |
-
|
| 210 |
-
def wrapped_table(table_name: str, *args, **kwargs) -> Any:
|
| 211 |
-
builder = original_table(table_name, *args, **kwargs)
|
| 212 |
-
if table_name == "tickets":
|
| 213 |
-
return WrappedRequestBuilder(builder, table_name)
|
| 214 |
-
return builder
|
| 215 |
-
|
| 216 |
-
client.table = wrapped_table
|
| 217 |
-
client._wrapped_by_crypto = True
|
| 218 |
-
return client
|
|
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import hashlib
|
| 3 |
+
import base64
|
| 4 |
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 5 |
+
|
| 6 |
+
# Initialize AESGCM with 32-byte key derived from secret key
|
| 7 |
+
SECRET_KEY = os.environ.get("DB_ENCRYPTION_SECRET_KEY")
|
| 8 |
+
|
| 9 |
+
_cipher = None
|
| 10 |
+
if SECRET_KEY:
|
| 11 |
+
# Hash secret key to ensure it is exactly 32 bytes (256 bits)
|
| 12 |
+
key_bytes = hashlib.sha256(SECRET_KEY.encode()).digest()
|
| 13 |
+
_cipher = AESGCM(key_bytes)
|
| 14 |
+
else:
|
| 15 |
+
print("[WARNING] DB_ENCRYPTION_SECRET_KEY not set. Data encryption is disabled (degraded mode).")
|
| 16 |
+
|
| 17 |
+
def encrypt(plain_text: str) -> str:
|
| 18 |
+
"""Encrypt plain text using AES-256 GCM and return base64 encoded string."""
|
| 19 |
+
if not _cipher or not plain_text:
|
| 20 |
+
return plain_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
try:
|
| 22 |
+
# Generate 12-byte random nonce for GCM
|
| 23 |
nonce = os.urandom(12)
|
| 24 |
+
encrypted_bytes = _cipher.encrypt(nonce, plain_text.encode(), None)
|
| 25 |
+
# Combine nonce and ciphertext and encode as base64
|
| 26 |
+
combined = nonce + encrypted_bytes
|
| 27 |
+
return base64.b64encode(combined).decode('utf-8')
|
|
|
|
|
|
|
|
|
|
| 28 |
except Exception as e:
|
| 29 |
+
print(f"[Crypto Error] Encryption failed: {e}")
|
| 30 |
+
return plain_text
|
| 31 |
|
| 32 |
+
def decrypt(cipher_text: str) -> str:
|
| 33 |
+
"""Decrypt base64 encoded ciphertext using AES-256 GCM and return plain text."""
|
| 34 |
+
if not _cipher or not cipher_text:
|
| 35 |
+
return cipher_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
try:
|
| 37 |
+
# Check if cipher_text looks like base64
|
| 38 |
+
try:
|
| 39 |
+
combined = base64.b64decode(cipher_text.encode('utf-8'))
|
| 40 |
+
except Exception:
|
| 41 |
+
return cipher_text # Return as-is if not valid base64
|
| 42 |
+
|
| 43 |
+
if len(combined) < 12:
|
| 44 |
+
return cipher_text # Not enough bytes for nonce
|
| 45 |
+
|
| 46 |
+
nonce = combined[:12]
|
| 47 |
+
ciphertext = combined[12:]
|
| 48 |
+
decrypted_bytes = _cipher.decrypt(nonce, ciphertext, None)
|
| 49 |
return decrypted_bytes.decode('utf-8')
|
| 50 |
except Exception as e:
|
| 51 |
+
# If decryption fails, it might be unencrypted plaintext (graceful degrade for old records)
|
| 52 |
+
return cipher_text
|
| 53 |
|
| 54 |
+
def apply_db_encryption_patch():
|
| 55 |
+
"""Apply transparent monkeypatch to Postgrest/Supabase client execute method for 'tickets' table."""
|
| 56 |
+
try:
|
| 57 |
+
from postgrest._sync.request_builder import SyncQueryRequestBuilder
|
| 58 |
+
|
| 59 |
+
_original_execute = SyncQueryRequestBuilder.execute
|
| 60 |
+
|
| 61 |
+
def custom_execute(self):
|
| 62 |
+
path_str = getattr(self.request.path, "path", "")
|
| 63 |
+
table_name = path_str.split('/')[-1]
|
| 64 |
+
|
| 65 |
+
if table_name == "tickets":
|
| 66 |
+
payload = self.request.json
|
| 67 |
+
if isinstance(payload, dict):
|
| 68 |
+
for field in ["contact_email", "description", "raw_text"]:
|
| 69 |
+
if field in payload and payload[field] is not None:
|
| 70 |
+
payload[field] = encrypt(str(payload[field]))
|
| 71 |
+
elif isinstance(payload, list):
|
| 72 |
+
for item in payload:
|
| 73 |
+
if isinstance(item, dict):
|
| 74 |
+
for field in ["contact_email", "description", "raw_text"]:
|
| 75 |
+
if field in item and item[field] is not None:
|
| 76 |
+
item[field] = encrypt(str(item[field]))
|
| 77 |
+
|
| 78 |
+
res = _original_execute(self)
|
| 79 |
+
|
| 80 |
+
if table_name == "tickets" and res and hasattr(res, "data"):
|
| 81 |
+
data = res.data
|
| 82 |
+
if isinstance(data, dict):
|
| 83 |
+
for field in ["contact_email", "description", "raw_text"]:
|
| 84 |
+
if field in data and data[field] is not None:
|
| 85 |
+
data[field] = decrypt(str(data[field]))
|
| 86 |
+
elif isinstance(data, list):
|
| 87 |
+
for item in data:
|
| 88 |
+
if isinstance(item, dict):
|
| 89 |
+
for field in ["contact_email", "description", "raw_text"]:
|
| 90 |
+
if field in item and item[field] is not None:
|
| 91 |
+
item[field] = decrypt(str(item[field]))
|
| 92 |
+
|
| 93 |
+
return res
|
| 94 |
+
|
| 95 |
+
SyncQueryRequestBuilder.execute = custom_execute
|
| 96 |
+
print("[Crypto] Supabase database encryption patch applied successfully.")
|
| 97 |
+
except Exception as e:
|
| 98 |
+
print(f"[Crypto WARNING] Failed to apply database encryption patch: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/main.py
CHANGED
|
@@ -8,7 +8,6 @@ import os
|
|
| 8 |
import sys
|
| 9 |
import uuid
|
| 10 |
import json
|
| 11 |
-
import re
|
| 12 |
import datetime
|
| 13 |
import traceback
|
| 14 |
import warnings
|
|
@@ -20,13 +19,13 @@ from contextlib import asynccontextmanager
|
|
| 20 |
warnings.filterwarnings("ignore", message="'pin_memory'")
|
| 21 |
|
| 22 |
# HF Rebuild Trigger: 2026-03-08-2030
|
| 23 |
-
from fastapi import FastAPI, Depends, HTTPException, Request,
|
| 24 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
|
|
| 25 |
from slowapi.util import get_remote_address
|
| 26 |
from slowapi.errors import RateLimitExceeded
|
| 27 |
from fastapi.middleware.cors import CORSMiddleware
|
| 28 |
-
from fastapi.responses import HTMLResponse, JSONResponse,
|
| 29 |
-
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
| 30 |
from fastapi.encoders import jsonable_encoder
|
| 31 |
import asyncio
|
| 32 |
from pathlib import Path
|
|
@@ -37,12 +36,13 @@ from dotenv import load_dotenv
|
|
| 37 |
env_path = Path(__file__).parent / '.env'
|
| 38 |
load_dotenv(dotenv_path=env_path)
|
| 39 |
|
| 40 |
-
#
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
def _startup_fatal(message: str) -> None:
|
| 45 |
-
print(f"[Startup-FATAL] {message}")
|
| 46 |
|
| 47 |
# Initialize Supabase Client (Service Role for backend bypass)
|
| 48 |
try:
|
|
@@ -53,8 +53,7 @@ try:
|
|
| 53 |
print("[ERROR] SUPABASE_URL or SUPABASE_SERVICE_KEY not set in backend/.env")
|
| 54 |
supabase = None
|
| 55 |
else:
|
| 56 |
-
|
| 57 |
-
supabase = wrap_client(create_client(url, key))
|
| 58 |
except (ImportError, Exception) as e:
|
| 59 |
print(f"[WARNING] Supabase initialization failed: {e}")
|
| 60 |
supabase = None
|
|
@@ -66,170 +65,17 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")
|
|
| 66 |
from backend.services.classifier_service import ClassifierService
|
| 67 |
from backend.services.classifier_v2 import classifier_v2
|
| 68 |
from backend.services.classifier_v3 import classifier_v3 # V3 Power Model
|
| 69 |
-
from backend.services.audit_service import AuditLogService, AuditLogAccessError
|
| 70 |
-
from backend.services.onnx_service import onnx_classifier
|
| 71 |
from backend.services.ner_service import NERService
|
| 72 |
from backend.services.duplicate_service import DuplicateService
|
| 73 |
-
from backend.services.semantic_duplicate_service import SemanticDuplicateService
|
| 74 |
from backend.services.rag_service import RagService
|
| 75 |
-
from backend.services.
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
# ---------------------------------------------------------------------------
|
| 84 |
-
|
| 85 |
-
HEARTBEAT_INTERVAL = 30 # seconds between ping broadcasts
|
| 86 |
-
HEARTBEAT_TIMEOUT = 10 # seconds to wait for a pong before disconnect
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
class ConnectionManager:
|
| 90 |
-
"""Tracks active WebSocket connections grouped by ``company_id``.
|
| 91 |
-
|
| 92 |
-
Thread-safe for concurrent connect/disconnect calls from multiple
|
| 93 |
-
ASGI workers (single-process via ``asyncio.Lock``).
|
| 94 |
-
"""
|
| 95 |
-
|
| 96 |
-
def __init__(self) -> None:
|
| 97 |
-
self._connections: dict[str, set[WebSocket]] = {}
|
| 98 |
-
self._lock = asyncio.Lock()
|
| 99 |
-
|
| 100 |
-
async def connect(self, company_id: str, ws: WebSocket) -> None:
|
| 101 |
-
"""Accept a new WebSocket and register it under ``company_id``."""
|
| 102 |
-
await ws.accept()
|
| 103 |
-
async with self._lock:
|
| 104 |
-
self._connections.setdefault(company_id, set()).add(ws)
|
| 105 |
-
|
| 106 |
-
async def disconnect(self, company_id: str, ws: WebSocket) -> None:
|
| 107 |
-
"""Remove a WebSocket from the pool."""
|
| 108 |
-
async with self._lock:
|
| 109 |
-
connections = self._connections.get(company_id)
|
| 110 |
-
if connections:
|
| 111 |
-
connections.discard(ws)
|
| 112 |
-
# Clean up empty company groups
|
| 113 |
-
if not connections:
|
| 114 |
-
del self._connections[company_id]
|
| 115 |
-
|
| 116 |
-
async def broadcast(self, company_id: str, message: dict) -> int:
|
| 117 |
-
"""Send a JSON message to every client in a company group.
|
| 118 |
-
|
| 119 |
-
Returns:
|
| 120 |
-
Number of successfully sent messages.
|
| 121 |
-
"""
|
| 122 |
-
payload = json.dumps(message, default=str)
|
| 123 |
-
sent = 0
|
| 124 |
-
async with self._lock:
|
| 125 |
-
connections = set(self._connections.get(company_id, []))
|
| 126 |
-
|
| 127 |
-
for ws in connections:
|
| 128 |
-
try:
|
| 129 |
-
await ws.send_text(payload)
|
| 130 |
-
sent += 1
|
| 131 |
-
except Exception:
|
| 132 |
-
await self.disconnect(company_id, ws)
|
| 133 |
-
return sent
|
| 134 |
-
|
| 135 |
-
async def broadcast_all(self, message: dict) -> int:
|
| 136 |
-
"""Send a JSON message to **all** connected clients."""
|
| 137 |
-
payload = json.dumps(message, default=str)
|
| 138 |
-
sent = 0
|
| 139 |
-
async with self._lock:
|
| 140 |
-
all_connections = {
|
| 141 |
-
ws for group in self._connections.values() for ws in group
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
for ws in all_connections:
|
| 145 |
-
try:
|
| 146 |
-
await ws.send_text(payload)
|
| 147 |
-
sent += 1
|
| 148 |
-
except Exception:
|
| 149 |
-
pass
|
| 150 |
-
return sent
|
| 151 |
-
|
| 152 |
-
async def ping_all(self) -> None:
|
| 153 |
-
"""Send a ``{"type": "ping"}`` heartbeat to every connection.
|
| 154 |
-
|
| 155 |
-
Connections that fail to receive the ping are removed.
|
| 156 |
-
"""
|
| 157 |
-
async with self._lock:
|
| 158 |
-
# Snapshot all connections under lock so iteration is safe
|
| 159 |
-
snapshot = {
|
| 160 |
-
cid: set(ws_set) for cid, ws_set in self._connections.items()
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
for cid, ws_set in snapshot.items():
|
| 164 |
-
for ws in list(ws_set):
|
| 165 |
-
try:
|
| 166 |
-
await ws.send_json({"type": "ping"})
|
| 167 |
-
except Exception:
|
| 168 |
-
await self.disconnect(cid, ws)
|
| 169 |
-
|
| 170 |
-
@property
|
| 171 |
-
def active_count(self) -> int:
|
| 172 |
-
"""Total number of connected clients across all companies."""
|
| 173 |
-
return sum(len(ws_set) for ws_set in self._connections.values())
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
# Singleton — reused across lifespan and WebSocket route
|
| 177 |
-
connection_manager = ConnectionManager()
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
async def _heartbeat_loop() -> None:
|
| 181 |
-
"""Background task: broadcast ping every ``HEARTBEAT_INTERVAL`` seconds.
|
| 182 |
-
|
| 183 |
-
Clients that fail the ping are disconnected automatically by
|
| 184 |
-
``ConnectionManager.ping_all()``.
|
| 185 |
-
"""
|
| 186 |
-
while True:
|
| 187 |
-
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
| 188 |
-
try:
|
| 189 |
-
await connection_manager.ping_all()
|
| 190 |
-
count = connection_manager.active_count
|
| 191 |
-
if count:
|
| 192 |
-
print(f"[WS] Heartbeat sent to {count} active connection(s)")
|
| 193 |
-
except Exception as exc:
|
| 194 |
-
print(f"[WS] Heartbeat error: {exc}")
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
# ---------------------------------------------------------------------------
|
| 198 |
-
# SLA helper functions (must be defined before save_ticket uses them)
|
| 199 |
-
# ---------------------------------------------------------------------------
|
| 200 |
-
|
| 201 |
-
def calculate_sla_breach_at(priority: str) -> datetime.datetime:
|
| 202 |
-
"""Return the UTC datetime by which the ticket must be resolved."""
|
| 203 |
-
hours_map = {"critical": 2, "high": 8, "medium": 24, "low": 72}
|
| 204 |
-
hours = hours_map.get(str(priority).lower().strip(), 72)
|
| 205 |
-
return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
def calculate_sla_response_at(priority: str) -> datetime.datetime:
|
| 209 |
-
"""Return the UTC datetime by which the ticket must receive a first response."""
|
| 210 |
-
hours_map = {"critical": 0.5, "high": 2, "medium": 6, "low": 18}
|
| 211 |
-
hours = hours_map.get(str(priority).lower().strip(), 6)
|
| 212 |
-
return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
def classify_sla_status(sla_breach_at: str | None) -> str:
|
| 216 |
-
"""Return 'BREACHED', 'WARNING', or 'ACTIVE' based on the breach time."""
|
| 217 |
-
if not sla_breach_at:
|
| 218 |
-
return "ACTIVE"
|
| 219 |
-
try:
|
| 220 |
-
clean_val = str(sla_breach_at).replace("Z", "+00:00")
|
| 221 |
-
deadline = datetime.datetime.fromisoformat(clean_val)
|
| 222 |
-
if deadline.tzinfo is None:
|
| 223 |
-
deadline = deadline.replace(tzinfo=datetime.timezone.utc)
|
| 224 |
-
except Exception:
|
| 225 |
-
return "ACTIVE"
|
| 226 |
-
|
| 227 |
-
now = datetime.datetime.now(datetime.timezone.utc)
|
| 228 |
-
if deadline <= now:
|
| 229 |
-
return "BREACHED"
|
| 230 |
-
if deadline - now <= datetime.timedelta(hours=1):
|
| 231 |
-
return "WARNING"
|
| 232 |
-
return "ACTIVE"
|
| 233 |
|
| 234 |
|
| 235 |
# ---------------------------------------------------------------------------
|
|
@@ -278,62 +124,13 @@ def detect_semantic_duplicate(text: str, *, company_id: str | None, threshold: f
|
|
| 278 |
duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
|
| 279 |
duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
|
| 280 |
return duplicate_result
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
def classify_ticket_text(text: str) -> dict:
|
| 284 |
-
"""Run the local classifier cascade with ONNX as the offline fallback path."""
|
| 285 |
-
cached = redis_cache.get_classification(text)
|
| 286 |
-
if cached:
|
| 287 |
-
return cached
|
| 288 |
-
|
| 289 |
-
result = _classify_ticket_text_uncached(text)
|
| 290 |
-
redis_cache.set_classification(text, result)
|
| 291 |
-
return result
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
def _classify_ticket_text_uncached(text: str) -> dict:
|
| 295 |
-
try:
|
| 296 |
-
classification_v3_res = classifier_v3.predict(text)
|
| 297 |
-
if "error" not in classification_v3_res:
|
| 298 |
-
cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
|
| 299 |
-
sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
|
| 300 |
-
pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
|
| 301 |
-
conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
|
| 302 |
-
|
| 303 |
-
from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
|
| 304 |
-
return {
|
| 305 |
-
"category": cat,
|
| 306 |
-
"subcategory": sub,
|
| 307 |
-
"priority": pri,
|
| 308 |
-
"auto_resolve": sub in AUTO_RESOLVE_SUBS,
|
| 309 |
-
"assigned_team": TEAM_MAP.get(cat, "General Support"),
|
| 310 |
-
"confidence": float(conf),
|
| 311 |
-
}
|
| 312 |
-
except Exception:
|
| 313 |
-
traceback.print_exc()
|
| 314 |
-
|
| 315 |
-
try:
|
| 316 |
-
onnx_result = onnx_classifier.predict(text)
|
| 317 |
-
if onnx_result:
|
| 318 |
-
return onnx_result
|
| 319 |
-
except Exception as error:
|
| 320 |
-
print(f"[ONNX] Fallback classification skipped: {error}")
|
| 321 |
-
|
| 322 |
-
try:
|
| 323 |
-
return classifier_service.predict(text)
|
| 324 |
-
except Exception:
|
| 325 |
-
traceback.print_exc()
|
| 326 |
-
return {
|
| 327 |
-
"category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
|
| 328 |
-
"auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
class TicketRequest(BaseModel):
|
| 332 |
text: str
|
| 333 |
image_base64: str = ""
|
| 334 |
image_text: str = "" # Keep for backward compatibility
|
| 335 |
user_id: str | None = None
|
| 336 |
company: str | None = None
|
|
|
|
| 337 |
image_url: str | None = None
|
| 338 |
confidence_threshold: float = 0.20
|
| 339 |
duplicate_sensitivity: float = 0.85
|
|
@@ -353,6 +150,10 @@ class TicketSaveRequest(BaseModel):
|
|
| 353 |
image_url: str | None = None
|
| 354 |
company: str | None = None
|
| 355 |
company_id: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
sla_breach_at: str
|
| 357 |
sla_status: str | None = None
|
| 358 |
escalation_level: int = 0
|
|
@@ -368,6 +169,8 @@ class TicketSaveRequest(BaseModel):
|
|
| 368 |
class DuplicateInfo(BaseModel):
|
| 369 |
is_duplicate: bool
|
| 370 |
duplicate_ticket_id: str | None = None
|
|
|
|
|
|
|
| 371 |
similarity: float = 0.0
|
| 372 |
|
| 373 |
|
|
@@ -377,14 +180,6 @@ class EntityInfo(BaseModel):
|
|
| 377 |
confidence: float
|
| 378 |
|
| 379 |
|
| 380 |
-
class SpamCheck(BaseModel):
|
| 381 |
-
is_spam: bool = False
|
| 382 |
-
risk_score: float = 0.0
|
| 383 |
-
reasons: list[str] = []
|
| 384 |
-
suspicious_urls: list[str] = []
|
| 385 |
-
matched_keywords: list[str] = []
|
| 386 |
-
|
| 387 |
-
|
| 388 |
class TicketResponse(BaseModel):
|
| 389 |
id: str | int | None = None
|
| 390 |
ticket_id: str | None = None
|
|
@@ -397,6 +192,8 @@ class TicketResponse(BaseModel):
|
|
| 397 |
entities: list[EntityInfo]
|
| 398 |
duplicate_ticket: DuplicateInfo
|
| 399 |
confidence: float
|
|
|
|
|
|
|
| 400 |
needs_review: bool = False
|
| 401 |
reasoning: str = ""
|
| 402 |
decision_factors: list[str] = []
|
|
@@ -406,11 +203,7 @@ class TicketResponse(BaseModel):
|
|
| 406 |
timeline: dict = {} # Map of step_name: timestamp
|
| 407 |
env_metadata: dict = {} # IP, Hostname, Browser/OS
|
| 408 |
sla_breach_at: str | None = None
|
| 409 |
-
|
| 410 |
-
source_language: str = "en"
|
| 411 |
-
source_language_name: str = "English"
|
| 412 |
-
was_translated: bool = False
|
| 413 |
-
spam_check: SpamCheck = SpamCheck()
|
| 414 |
version: str = "2.1.0-Neural-Diagnostic"
|
| 415 |
|
| 416 |
|
|
@@ -438,24 +231,6 @@ class TicketRecord(BaseModel):
|
|
| 438 |
timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
|
| 439 |
|
| 440 |
|
| 441 |
-
class AuditLogProfile(BaseModel):
|
| 442 |
-
full_name: str | None = None
|
| 443 |
-
email: str | None = None
|
| 444 |
-
profile_picture: str | None = None
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
class AuditLogRecord(BaseModel):
|
| 448 |
-
id: str
|
| 449 |
-
ticket_id: str
|
| 450 |
-
company_id: str
|
| 451 |
-
performed_by: str | None = None
|
| 452 |
-
action: str
|
| 453 |
-
old_value: dict | list | str | None = None
|
| 454 |
-
new_value: dict | list | str | None = None
|
| 455 |
-
created_at: str
|
| 456 |
-
performed_by_profile: AuditLogProfile | None = None
|
| 457 |
-
|
| 458 |
-
|
| 459 |
# --- In-Memory Database (to be replaced with SQL later) ---
|
| 460 |
TICKETS_DB: list[TicketRecord] = []
|
| 461 |
|
|
@@ -478,9 +253,6 @@ classifier_service = ClassifierService()
|
|
| 478 |
ner_service = NERService()
|
| 479 |
duplicate_service = DuplicateService()
|
| 480 |
rag_service = RagService()
|
| 481 |
-
spam_service = SpamService()
|
| 482 |
-
sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
|
| 483 |
-
semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
|
| 484 |
|
| 485 |
try:
|
| 486 |
from backend.services.gemini_service import GeminiService
|
|
@@ -494,82 +266,6 @@ try:
|
|
| 494 |
except ImportError:
|
| 495 |
ocr_service = None
|
| 496 |
|
| 497 |
-
LANGUAGE_NAMES = {
|
| 498 |
-
"en": "English",
|
| 499 |
-
"es": "Spanish",
|
| 500 |
-
"de": "German",
|
| 501 |
-
"hi": "Hindi",
|
| 502 |
-
"fr": "French",
|
| 503 |
-
"it": "Italian",
|
| 504 |
-
"pt": "Portuguese",
|
| 505 |
-
"ja": "Japanese",
|
| 506 |
-
"ko": "Korean",
|
| 507 |
-
"zh": "Chinese",
|
| 508 |
-
"ar": "Arabic",
|
| 509 |
-
"ru": "Russian",
|
| 510 |
-
}
|
| 511 |
-
|
| 512 |
-
def _heuristic_language_detection(text: str) -> dict:
|
| 513 |
-
sample = (text or "").strip()
|
| 514 |
-
if not sample:
|
| 515 |
-
return {"code": "en", "name": "English"}
|
| 516 |
-
ascii_chars = sum(1 for c in sample if ord(c) < 128)
|
| 517 |
-
ratio = ascii_chars / max(len(sample), 1)
|
| 518 |
-
if ratio > 0.97:
|
| 519 |
-
return {"code": "en", "name": "English"}
|
| 520 |
-
return {"code": "unknown", "name": "Unknown"}
|
| 521 |
-
|
| 522 |
-
def detect_and_translate_ticket_text(text: str) -> dict:
|
| 523 |
-
original_text = (text or "").strip()
|
| 524 |
-
if not original_text:
|
| 525 |
-
return {
|
| 526 |
-
"text_for_analysis": text or "",
|
| 527 |
-
"source_language": "en",
|
| 528 |
-
"source_language_name": "English",
|
| 529 |
-
"was_translated": False,
|
| 530 |
-
"original_text": "",
|
| 531 |
-
"metadata":{},
|
| 532 |
-
}
|
| 533 |
-
|
| 534 |
-
detected = _heuristic_language_detection(original_text)
|
| 535 |
-
if gemini_service and getattr(gemini_service, "_initialized", False):
|
| 536 |
-
detected = gemini_service.detect_language(original_text)
|
| 537 |
-
|
| 538 |
-
source_code = str(detected.get("code", "en")).lower()
|
| 539 |
-
source_name = detected.get("name") or LANGUAGE_NAMES.get(source_code, source_code.upper())
|
| 540 |
-
if source_code in ("en", "eng"):
|
| 541 |
-
return {
|
| 542 |
-
"text_for_analysis": original_text,
|
| 543 |
-
"source_language": "en",
|
| 544 |
-
"source_language_name": "English",
|
| 545 |
-
"was_translated": False,
|
| 546 |
-
"original_text": original_text,
|
| 547 |
-
"metadata":{},
|
| 548 |
-
}
|
| 549 |
-
|
| 550 |
-
translated_text = original_text
|
| 551 |
-
if gemini_service and getattr(gemini_service, "_initialized", False):
|
| 552 |
-
translated_text = gemini_service.translate_to_english(original_text, source_name)
|
| 553 |
-
|
| 554 |
-
if not translated_text or translated_text.strip() == original_text:
|
| 555 |
-
return {
|
| 556 |
-
"text_for_analysis": original_text,
|
| 557 |
-
"source_language": source_code,
|
| 558 |
-
"source_language_name": source_name,
|
| 559 |
-
"was_translated": False,
|
| 560 |
-
"original_text": original_text,
|
| 561 |
-
"metadata":{},
|
| 562 |
-
}
|
| 563 |
-
|
| 564 |
-
return {
|
| 565 |
-
"text_for_analysis": translated_text.strip(),
|
| 566 |
-
"source_language": source_code,
|
| 567 |
-
"source_language_name": source_name,
|
| 568 |
-
"was_translated": True,
|
| 569 |
-
"original_text": original_text,
|
| 570 |
-
"metadata":{},
|
| 571 |
-
}
|
| 572 |
-
|
| 573 |
|
| 574 |
# ---------------------------------------------------------------------------
|
| 575 |
# Lifespan (startup / shutdown)
|
|
@@ -578,17 +274,13 @@ def detect_and_translate_ticket_text(text: str) -> dict:
|
|
| 578 |
async def lifespan(app: FastAPI):
|
| 579 |
"""Load all models at startup."""
|
| 580 |
print("[Startup] Loading AI models ...")
|
| 581 |
-
try:
|
| 582 |
-
redis_cache.connect()
|
| 583 |
-
except Exception as e:
|
| 584 |
-
print(f"[WARNING] Redis cache not available: {e}")
|
| 585 |
try:
|
| 586 |
classifier_service.load()
|
| 587 |
-
except
|
| 588 |
print(f"[WARNING] Classifier not loaded: {e}")
|
| 589 |
try:
|
| 590 |
ner_service.load()
|
| 591 |
-
except
|
| 592 |
print(f"[WARNING] NER not loaded: {e}")
|
| 593 |
try:
|
| 594 |
duplicate_service.load()
|
|
@@ -598,51 +290,50 @@ async def lifespan(app: FastAPI):
|
|
| 598 |
rag_service.load()
|
| 599 |
except Exception as e:
|
| 600 |
print(f"[WARNING] RAG service not loaded: {e}")
|
| 601 |
-
try:
|
| 602 |
-
onnx_classifier.load()
|
| 603 |
-
except Exception as e:
|
| 604 |
-
print(f"[WARNING] ONNX classifier fallback not loaded: {e}")
|
| 605 |
|
| 606 |
if gemini_service:
|
| 607 |
print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
|
| 608 |
else:
|
| 609 |
print("[Startup] Gemini Service: NOT LOADED (Import failed)")
|
| 610 |
|
| 611 |
-
# Wire services with supabase client
|
| 612 |
-
sla_engine.supabase = supabase
|
| 613 |
-
semantic_dupe_service.supabase = supabase
|
| 614 |
-
|
| 615 |
-
# Pre-load embedding model so first ticket save is fast
|
| 616 |
-
try:
|
| 617 |
-
semantic_dupe_service.load()
|
| 618 |
-
print(f"[Startup] Semantic Duplicate Detection: {'Loaded' if semantic_dupe_service._loaded else 'Failed (model missing)'}")
|
| 619 |
-
except Exception as e:
|
| 620 |
-
print(f"[Startup] Semantic Duplicate Detection load error: {e}")
|
| 621 |
-
print(f"[Startup] SLA Engine: {'Initialized' if supabase else 'Offline (no DB)'}")
|
| 622 |
-
|
| 623 |
-
# Start background SLA checker as an async task (every 5 minutes)
|
| 624 |
-
if supabase:
|
| 625 |
-
from backend.sla_checker import sla_checker_loop_async
|
| 626 |
-
asyncio.create_task(sla_checker_loop_async(supabase, interval_seconds=300))
|
| 627 |
-
print("[Startup] SLA background checker started (interval=300s)")
|
| 628 |
-
|
| 629 |
print("[Startup] Classifier V2 Shadow: Ready.")
|
| 630 |
-
print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
|
| 631 |
print("[Startup] Ready.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
print("[Startup] WebSocket heartbeat loop started (interval=30s).")
|
| 636 |
|
| 637 |
-
|
|
|
|
| 638 |
|
| 639 |
-
|
| 640 |
-
heartbeat_task.cancel()
|
| 641 |
try:
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
|
| 647 |
|
| 648 |
# ---------------------------------------------------------------------------
|
|
@@ -673,8 +364,6 @@ app.add_middleware(
|
|
| 673 |
allow_headers=["*"],
|
| 674 |
)
|
| 675 |
|
| 676 |
-
app.include_router(auth_cookie_router)
|
| 677 |
-
|
| 678 |
|
| 679 |
# ---------------------------------------------------------------------------
|
| 680 |
# Root & Health check
|
|
@@ -761,6 +450,17 @@ async def root():
|
|
| 761 |
"""
|
| 762 |
|
| 763 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
@app.get("/health", response_model=HealthResponse)
|
| 765 |
async def health_check():
|
| 766 |
return HealthResponse(
|
|
@@ -773,18 +473,29 @@ async def health_check():
|
|
| 773 |
@app.get("/ready", response_model=ReadinessResponse)
|
| 774 |
async def readiness_check():
|
| 775 |
require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
|
|
|
|
|
|
|
| 776 |
checks = {
|
| 777 |
"api": True,
|
| 778 |
"classifier_loaded": classifier_service._loaded,
|
| 779 |
"ner_loaded": ner_service._loaded,
|
| 780 |
-
"duplicate_index_loaded": duplicate_service.
|
| 781 |
-
"rag_loaded": rag_service.
|
| 782 |
}
|
| 783 |
if require_supabase:
|
| 784 |
checks["supabase_configured"] = supabase is not None
|
| 785 |
|
| 786 |
-
|
| 787 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
|
| 789 |
return JSONResponse(
|
| 790 |
status_code=503,
|
|
@@ -912,66 +623,44 @@ async def log_correction(raw_request: Request):
|
|
| 912 |
# ---------------------------------------------------------------------------
|
| 913 |
# Ticket operations (Now via Supabase)
|
| 914 |
# ---------------------------------------------------------------------------
|
| 915 |
-
MASTER_TICKET_ROLES = {"master_admin", "super_admin", "superadmin", "owner"}
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
def _get_auth_user_id(user: dict) -> str:
|
| 919 |
-
user_id = user.get("id") or user.get("sub") or user.get("user_id")
|
| 920 |
-
if not user_id:
|
| 921 |
-
raise HTTPException(status_code=401, detail="Invalid authenticated user")
|
| 922 |
-
return str(user_id)
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
def _get_authenticated_profile(user: dict) -> dict:
|
| 926 |
-
user_id = _get_auth_user_id(user)
|
| 927 |
-
res = (
|
| 928 |
-
supabase.table("profiles")
|
| 929 |
-
.select("id, company_id, company, role")
|
| 930 |
-
.eq("id", user_id)
|
| 931 |
-
.single()
|
| 932 |
-
.execute()
|
| 933 |
-
)
|
| 934 |
-
if not res.data:
|
| 935 |
-
raise HTTPException(status_code=403, detail="User profile not found")
|
| 936 |
-
return res.data
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
def _is_master_ticket_reader(profile: dict) -> bool:
|
| 940 |
-
role = str(profile.get("role") or "").lower()
|
| 941 |
-
return role in MASTER_TICKET_ROLES
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
def _ticket_company_scope(profile: dict, requested_company_id: str | None = None) -> str | None:
|
| 945 |
-
if _is_master_ticket_reader(profile):
|
| 946 |
-
return requested_company_id
|
| 947 |
-
|
| 948 |
-
company_id = profile.get("company_id")
|
| 949 |
-
if not company_id:
|
| 950 |
-
raise HTTPException(status_code=403, detail="User tenant is not configured")
|
| 951 |
-
if requested_company_id and requested_company_id != company_id:
|
| 952 |
-
raise HTTPException(status_code=403, detail="User not authorized for this tenant")
|
| 953 |
-
return str(company_id)
|
| 954 |
-
|
| 955 |
-
|
| 956 |
@app.get("/tickets")
|
| 957 |
-
async def get_tickets(
|
| 958 |
-
company_id: str | None = None,
|
| 959 |
-
current_user: dict = Depends(get_current_user),
|
| 960 |
-
):
|
| 961 |
"""Fetch persistent tickets from Supabase."""
|
| 962 |
if not supabase:
|
| 963 |
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 964 |
-
|
| 965 |
-
profile = _get_authenticated_profile(current_user)
|
| 966 |
-
company_scope = _ticket_company_scope(profile, company_id)
|
| 967 |
|
| 968 |
query = supabase.table("tickets").select("*").order("created_at", desc=True)
|
| 969 |
-
if
|
| 970 |
-
query = query.eq("company_id",
|
| 971 |
|
| 972 |
res = query.execute()
|
| 973 |
return res.data
|
| 974 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 975 |
@app.post("/tickets/save")
|
| 976 |
async def save_ticket(request_body: TicketSaveRequest):
|
| 977 |
"""
|
|
@@ -982,80 +671,64 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 982 |
raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
|
| 983 |
|
| 984 |
logger = logging.getLogger(__name__)
|
| 985 |
-
final_data = request_body.model_dump()
|
| 986 |
-
original_subject = final_data.get("subject", "") or ""
|
| 987 |
-
original_description = final_data.get("description", "") or ""
|
| 988 |
-
|
| 989 |
-
# Detect language and translate subject/description into English before downstream routing/indexing.
|
| 990 |
-
translation_probe_text = (original_description.strip() or original_subject.strip())
|
| 991 |
-
translation_ctx = detect_and_translate_ticket_text(translation_probe_text)
|
| 992 |
-
metadata = final_data.get("metadata") or {}
|
| 993 |
-
if translation_ctx["was_translated"]:
|
| 994 |
-
translated_subject = gemini_service.translate_to_english(original_subject, translation_ctx["source_language_name"]) if original_subject else original_subject
|
| 995 |
-
translated_description = gemini_service.translate_to_english(original_description, translation_ctx["source_language_name"]) if original_description else original_description
|
| 996 |
-
final_data["subject"] = translated_subject or original_subject
|
| 997 |
-
final_data["description"] = translated_description or original_description
|
| 998 |
-
metadata["original_text"] = {
|
| 999 |
-
"subject": original_subject,
|
| 1000 |
-
"description": original_description,
|
| 1001 |
-
}
|
| 1002 |
-
metadata["translation"] = {
|
| 1003 |
-
"translated": bool(translation_ctx["was_translated"]),
|
| 1004 |
-
"source_language": translation_ctx["source_language"],
|
| 1005 |
-
"source_language_name": translation_ctx["source_language_name"],
|
| 1006 |
-
}
|
| 1007 |
-
final_data["metadata"] = metadata
|
| 1008 |
-
|
| 1009 |
-
# Backfill SLA deadlines/status when the client omits or sends empty values.
|
| 1010 |
-
priority_key = str(final_data.get("priority") or "medium").lower().strip()
|
| 1011 |
-
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
| 1012 |
-
|
| 1013 |
-
if not str(final_data.get("sla_breach_at") or "").strip():
|
| 1014 |
-
final_data["sla_breach_at"] = compute_sla_breach_at(priority_key, now_utc)
|
| 1015 |
-
|
| 1016 |
-
if not str(final_data.get("sla_response_due_at") or "").strip():
|
| 1017 |
-
policy = get_sla_policy(priority_key)
|
| 1018 |
-
response_hours = max(1, int(round(float(policy["max_hours"]) * 0.25)))
|
| 1019 |
-
response_due_at = now_utc + datetime.timedelta(hours=response_hours)
|
| 1020 |
-
final_data["sla_response_due_at"] = response_due_at.isoformat()
|
| 1021 |
-
|
| 1022 |
-
if not str(final_data.get("sla_status") or "").strip():
|
| 1023 |
-
final_data["sla_status"] = "ACTIVE"
|
| 1024 |
-
# Resolve tenant linkage from user profile with authorization validation.
|
| 1025 |
-
profile = {}
|
| 1026 |
-
if request_body.user_id:
|
| 1027 |
-
try:
|
| 1028 |
-
profile_res = (
|
| 1029 |
-
supabase.table("profiles")
|
| 1030 |
-
.select("company_id, company")
|
| 1031 |
-
.eq("id", request_body.user_id)
|
| 1032 |
-
.single()
|
| 1033 |
-
.execute()
|
| 1034 |
-
)
|
| 1035 |
-
profile = profile_res.data or {}
|
| 1036 |
-
if not profile:
|
| 1037 |
-
raise HTTPException(status_code=404, detail="User profile not found")
|
| 1038 |
-
except HTTPException:
|
| 1039 |
-
raise
|
| 1040 |
-
except Exception as profile_error:
|
| 1041 |
-
logger.error(f"Tenant resolution error for user {request_body.user_id}: {profile_error}")
|
| 1042 |
-
raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
|
| 1043 |
-
|
| 1044 |
-
# Validate tenant consistency and authorization.
|
| 1045 |
-
profile_company_id = profile.get("company_id")
|
| 1046 |
-
if final_data.get("company_id"):
|
| 1047 |
-
# User provided company_id: verify it matches their profile.
|
| 1048 |
-
if profile_company_id and final_data["company_id"] != profile_company_id:
|
| 1049 |
-
logger.warning(f"Tenant mismatch: user {request_body.user_id} attempted {final_data['company_id']}, assigned to {profile_company_id}")
|
| 1050 |
-
raise HTTPException(status_code=403, detail="User not authorized for this tenant")
|
| 1051 |
-
elif profile_company_id:
|
| 1052 |
-
# Backfill company_id from profile.
|
| 1053 |
-
final_data["company_id"] = profile_company_id
|
| 1054 |
-
elif request_body.user_id:
|
| 1055 |
-
# User has no tenant assignment.
|
| 1056 |
-
raise HTTPException(status_code=400, detail="User has no tenant assignment")
|
| 1057 |
-
|
| 1058 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1059 |
# Backfill company name if missing.
|
| 1060 |
if not final_data.get("company") and profile.get("company"):
|
| 1061 |
final_data["company"] = profile["company"]
|
|
@@ -1068,31 +741,32 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 1068 |
final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
|
| 1069 |
final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
|
| 1070 |
|
|
|
|
| 1071 |
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 1072 |
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 1073 |
|
| 1074 |
duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
|
| 1075 |
-
duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1076 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1077 |
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
duplicate_check_result = None
|
| 1081 |
-
try:
|
| 1082 |
-
dupe_text = (request_body.description or request_body.subject or "").strip()
|
| 1083 |
-
if dupe_text:
|
| 1084 |
-
duplicate_check_result = await semantic_dupe_service.check_duplicate(
|
| 1085 |
-
text=dupe_text,
|
| 1086 |
-
company_id=final_data.get("company_id"),
|
| 1087 |
-
)
|
| 1088 |
-
if duplicate_check_result["is_duplicate"]:
|
| 1089 |
-
logger.info(
|
| 1090 |
-
f"[DUPLICATE] Ticket flagged as potential duplicate of "
|
| 1091 |
-
f"{duplicate_check_result['duplicate_ticket_id']} "
|
| 1092 |
-
f"(similarity: {duplicate_check_result['similarity']})"
|
| 1093 |
-
)
|
| 1094 |
-
except Exception as e:
|
| 1095 |
-
logger.warning(f"[DUPLICATE] Semantic check error (non-fatal): {e}")
|
| 1096 |
|
| 1097 |
# --- Sanitize payload to only include valid Supabase DB columns ---
|
| 1098 |
# Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
|
|
@@ -1100,14 +774,14 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 1100 |
VALID_TICKET_COLUMNS = {
|
| 1101 |
"user_id", "subject", "description", "category", "subcategory",
|
| 1102 |
"priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
|
| 1103 |
-
"confidence", "image_url", "company", "company_id",
|
| 1104 |
-
"sla_breach_at", "sla_response_due_at", "sla_status", "escalation_level", "metadata",
|
| 1105 |
}
|
| 1106 |
# Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
|
| 1107 |
existing_metadata = final_data.get("metadata") or {}
|
| 1108 |
extra_keys = (
|
| 1109 |
"entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
|
| 1110 |
-
"is_potential_duplicate", "parent_ticket_id"
|
|
|
|
| 1111 |
)
|
| 1112 |
for extra_key in extra_keys:
|
| 1113 |
if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
|
|
@@ -1124,29 +798,19 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 1124 |
|
| 1125 |
ticket_id = res.data[0]["id"]
|
| 1126 |
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
try:
|
| 1130 |
-
supabase.table("tickets").update({
|
| 1131 |
-
"is_potential_duplicate": True,
|
| 1132 |
-
"parent_ticket_id": duplicate_check_result["duplicate_ticket_id"],
|
| 1133 |
-
}).eq("id", ticket_id).execute()
|
| 1134 |
-
except Exception as e:
|
| 1135 |
-
logger.warning(f"[DUPLICATE] Failed to link parent ticket: {e}")
|
| 1136 |
-
|
| 1137 |
-
# Index the new ticket's embedding for future duplicate checks
|
| 1138 |
-
embedding_indexed = False
|
| 1139 |
-
description_text = (request_body.description or "").strip()
|
| 1140 |
-
subject_text = (request_body.subject or "").strip()
|
| 1141 |
-
duplicate_text = description_text or subject_text
|
| 1142 |
if duplicate_text:
|
| 1143 |
try:
|
| 1144 |
-
# Both: old in-memory index (for backward compat) and new pgvector index
|
| 1145 |
duplicate_service.add_ticket(str(ticket_id), duplicate_text)
|
| 1146 |
-
asyncio.create_task(semantic_dupe_service.index_ticket(ticket_id, duplicate_text))
|
| 1147 |
-
embedding_indexed = True
|
| 1148 |
except Exception as index_error:
|
| 1149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1150 |
|
| 1151 |
# Add initial system diagnostic message
|
| 1152 |
msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
|
|
@@ -1164,157 +828,30 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 1164 |
response = {
|
| 1165 |
"status": "success",
|
| 1166 |
"ticket_id": ticket_id,
|
| 1167 |
-
"duplicate_indexed":
|
|
|
|
|
|
|
| 1168 |
}
|
| 1169 |
-
if
|
| 1170 |
-
response["
|
| 1171 |
-
response["parent_ticket_id"] = duplicate_check_result["duplicate_ticket_id"]
|
| 1172 |
-
response["parent_subject"] = duplicate_check_result.get("parent_subject")
|
| 1173 |
-
response["similarity"] = duplicate_check_result["similarity"]
|
| 1174 |
-
response["candidates"] = duplicate_check_result.get("candidates", [])
|
| 1175 |
-
|
| 1176 |
-
# Broadcast the new/updated ticket to all WebSocket clients for this company
|
| 1177 |
-
company_id = final_data.get("company_id")
|
| 1178 |
-
if company_id:
|
| 1179 |
-
asyncio.create_task(
|
| 1180 |
-
connection_manager.broadcast(
|
| 1181 |
-
company_id,
|
| 1182 |
-
{
|
| 1183 |
-
"type": "ticket_update",
|
| 1184 |
-
"event": "created",
|
| 1185 |
-
"ticket": insert_data,
|
| 1186 |
-
"ticket_id": str(ticket_id),
|
| 1187 |
-
},
|
| 1188 |
-
)
|
| 1189 |
-
)
|
| 1190 |
return response
|
| 1191 |
|
| 1192 |
except Exception as e:
|
| 1193 |
traceback.print_exc()
|
| 1194 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1195 |
|
| 1196 |
-
@app.websocket("/ws/{company_id}")
|
| 1197 |
-
async def websocket_endpoint(ws: WebSocket, company_id: str):
|
| 1198 |
-
"""Real-time WebSocket feed for a company's ticket dashboard.
|
| 1199 |
-
|
| 1200 |
-
Protocol:
|
| 1201 |
-
- Server sends ``{"type": "ping"}`` every 30s (heartbeat).
|
| 1202 |
-
- Client must respond with ``{"type": "pong"}`` within 10s.
|
| 1203 |
-
- Server pushes ``{"type": "ticket_update", ...}`` on changes.
|
| 1204 |
-
|
| 1205 |
-
Usage (frontend):
|
| 1206 |
-
const socket = new WebSocket("ws://host:7860/ws/{company_id}");
|
| 1207 |
-
socket.onmessage = (event) => { const msg = JSON.parse(event.data); };
|
| 1208 |
-
"""
|
| 1209 |
-
if not company_id or not company_id.strip():
|
| 1210 |
-
await ws.close(code=4000, reason="Missing company_id")
|
| 1211 |
-
return
|
| 1212 |
-
|
| 1213 |
-
company_id = company_id.strip()
|
| 1214 |
-
await connection_manager.connect(company_id, ws)
|
| 1215 |
-
print(f"[WS] Client connected — company_id={company_id}")
|
| 1216 |
-
|
| 1217 |
-
try:
|
| 1218 |
-
while True:
|
| 1219 |
-
raw = await ws.receive_text()
|
| 1220 |
-
if not raw.strip():
|
| 1221 |
-
continue
|
| 1222 |
-
try:
|
| 1223 |
-
data = json.loads(raw)
|
| 1224 |
-
except json.JSONDecodeError:
|
| 1225 |
-
continue # ignore malformed frames
|
| 1226 |
-
|
| 1227 |
-
# Handle pong response
|
| 1228 |
-
if data.get("type") == "pong":
|
| 1229 |
-
continue
|
| 1230 |
-
|
| 1231 |
-
except WebSocketDisconnect:
|
| 1232 |
-
pass
|
| 1233 |
-
except Exception as exc:
|
| 1234 |
-
print(f"[WS] Connection error for company_id={company_id}: {exc}")
|
| 1235 |
-
finally:
|
| 1236 |
-
await connection_manager.disconnect(company_id, ws)
|
| 1237 |
-
print(f"[WS] Client disconnected — company_id={company_id}")
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
@app.get("/tickets/{ticket_id}")
|
| 1241 |
-
async def get_ticket_by_id(
|
| 1242 |
-
request: Request,
|
| 1243 |
-
ticket_id: str,
|
| 1244 |
-
current_user: dict = Depends(get_current_user),
|
| 1245 |
-
):
|
| 1246 |
"""Fetch single persistent ticket."""
|
| 1247 |
if not supabase:
|
| 1248 |
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 1249 |
-
|
| 1250 |
-
# Guard route overlap where '/tickets/search' may be matched here first.
|
| 1251 |
-
if ticket_id == "search":
|
| 1252 |
-
return await search_tickets(
|
| 1253 |
-
q=request.query_params.get("q", ""),
|
| 1254 |
-
company_id=request.query_params.get("company_id"),
|
| 1255 |
-
current_user=current_user,
|
| 1256 |
-
)
|
| 1257 |
-
|
| 1258 |
-
profile = _get_authenticated_profile(current_user)
|
| 1259 |
-
company_scope = _ticket_company_scope(profile)
|
| 1260 |
res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
|
| 1261 |
if not res.data:
|
| 1262 |
raise HTTPException(status_code=404, detail="Ticket not found")
|
| 1263 |
-
if company_scope and res.data.get("company_id") != company_scope:
|
| 1264 |
-
raise HTTPException(status_code=403, detail="User not authorized for this tenant")
|
| 1265 |
return res.data
|
| 1266 |
|
| 1267 |
|
| 1268 |
-
@app.get("/tickets/{ticket_id}/audit_logs", response_model=list[AuditLogRecord])
|
| 1269 |
-
async def get_ticket_audit_logs(ticket_id: str, company_id: str):
|
| 1270 |
-
"""Return a company-scoped chronological audit trail for a ticket."""
|
| 1271 |
-
if not supabase:
|
| 1272 |
-
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 1273 |
-
|
| 1274 |
-
try:
|
| 1275 |
-
service = AuditLogService(supabase)
|
| 1276 |
-
return service.get_ticket_audit_logs(ticket_id, company_id)
|
| 1277 |
-
except AuditLogAccessError as err:
|
| 1278 |
-
raise HTTPException(status_code=err.status_code, detail=err.detail)
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
@app.get("/tickets/search")
|
| 1282 |
-
async def search_tickets(
|
| 1283 |
-
q: str,
|
| 1284 |
-
company_id: str | None = None,
|
| 1285 |
-
current_user: dict = Depends(get_current_user),
|
| 1286 |
-
):
|
| 1287 |
-
"""Search tickets by query text, optionally scoped by company_id."""
|
| 1288 |
-
if not supabase:
|
| 1289 |
-
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 1290 |
-
query_text = (q or "").strip()
|
| 1291 |
-
if not query_text:
|
| 1292 |
-
raise HTTPException(status_code=400, detail="Query text is required")
|
| 1293 |
-
|
| 1294 |
-
profile = _get_authenticated_profile(current_user)
|
| 1295 |
-
company_scope = _ticket_company_scope(profile, company_id)
|
| 1296 |
-
|
| 1297 |
-
try:
|
| 1298 |
-
rpc_res = supabase.rpc(
|
| 1299 |
-
"search_tickets",
|
| 1300 |
-
{"query_text": query_text, "company_id": company_scope},
|
| 1301 |
-
).execute()
|
| 1302 |
-
return rpc_res.data or []
|
| 1303 |
-
except Exception:
|
| 1304 |
-
# Fallback for environments without RPC function support.
|
| 1305 |
-
fallback = supabase.table("tickets").select("*").order("created_at", desc=True).execute()
|
| 1306 |
-
rows = fallback.data or []
|
| 1307 |
-
lowered = query_text.lower()
|
| 1308 |
-
filtered = [
|
| 1309 |
-
row for row in rows
|
| 1310 |
-
if lowered in str(row.get("subject", "")).lower()
|
| 1311 |
-
or lowered in str(row.get("description", "")).lower()
|
| 1312 |
-
]
|
| 1313 |
-
if company_scope:
|
| 1314 |
-
filtered = [row for row in filtered if row.get("company_id") == company_scope]
|
| 1315 |
-
return filtered
|
| 1316 |
-
|
| 1317 |
-
|
| 1318 |
@app.post("/tickets", response_model=TicketRecord)
|
| 1319 |
async def create_ticket(ticket: TicketRecord):
|
| 1320 |
"""Save a new ticket into the system."""
|
|
@@ -1353,6 +890,11 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
|
|
| 1353 |
Main endpoint for analyzing a new ticket using the cascade of local AI models.
|
| 1354 |
"""
|
| 1355 |
text = request_body.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1356 |
|
| 1357 |
# Grab client metadata
|
| 1358 |
client_ip = request.client.host if request.client else "unknown"
|
|
@@ -1374,9 +916,8 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
|
|
| 1374 |
text = f"{text} {local_ocr_text}".strip()
|
| 1375 |
print(f"[AI] OCR added {len(local_ocr_text)} chars to context.")
|
| 1376 |
|
| 1377 |
-
#
|
| 1378 |
-
|
| 1379 |
-
return await analyze_only(enriched)
|
| 1380 |
|
| 1381 |
@app.post("/ai/analyze")
|
| 1382 |
async def analyze_only(request_body: TicketRequest):
|
|
@@ -1386,8 +927,6 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1386 |
and duplicate check before committing to a ticket creation.
|
| 1387 |
"""
|
| 1388 |
text = request_body.text
|
| 1389 |
-
translation_ctx = detect_and_translate_ticket_text(text)
|
| 1390 |
-
text = translation_ctx["text_for_analysis"]
|
| 1391 |
print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
|
| 1392 |
settings = get_system_settings(request_body.company)
|
| 1393 |
confidence_threshold = settings["ai_confidence_threshold"]
|
|
@@ -1429,11 +968,9 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1429 |
highlights=[],
|
| 1430 |
timeline={"received": _dt.datetime.utcnow().isoformat() + "Z"},
|
| 1431 |
env_metadata={},
|
|
|
|
|
|
|
| 1432 |
sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
|
| 1433 |
-
original_text=request_body.text,
|
| 1434 |
-
source_language=translation_ctx["source_language"],
|
| 1435 |
-
source_language_name=translation_ctx["source_language_name"],
|
| 1436 |
-
was_translated=translation_ctx["was_translated"],
|
| 1437 |
)
|
| 1438 |
|
| 1439 |
# --- Context & Environment ---
|
|
@@ -1465,18 +1002,54 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1465 |
|
| 1466 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1467 |
|
| 1468 |
-
# --- Spam
|
|
|
|
|
|
|
|
|
|
| 1469 |
try:
|
| 1470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1471 |
except Exception as e:
|
| 1472 |
-
|
| 1473 |
-
|
| 1474 |
-
"
|
| 1475 |
-
"
|
| 1476 |
}
|
| 1477 |
|
| 1478 |
-
#
|
| 1479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1480 |
|
| 1481 |
timeline["ai_analyzed"] = get_now_ist()
|
| 1482 |
timeline["triaged"] = get_now_ist()
|
|
@@ -1490,10 +1063,21 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1490 |
timeline["metadata_harvested"] = get_now_ist()
|
| 1491 |
|
| 1492 |
# --- Duplicate detection ---
|
|
|
|
| 1493 |
try:
|
| 1494 |
-
dup_result =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1495 |
except Exception:
|
| 1496 |
-
dup_result = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1497 |
|
| 1498 |
# --- RAG Knowledge Base Check ---
|
| 1499 |
rag_match = None
|
|
@@ -1509,26 +1093,30 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1509 |
|
| 1510 |
# --- Reasoning ---
|
| 1511 |
decision_factors = []
|
| 1512 |
-
if classification["confidence"] > request_body.confidence_threshold:
|
| 1513 |
-
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1514 |
-
if entities:
|
| 1515 |
-
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
| 1516 |
-
if dup_result["is_duplicate"]:
|
| 1517 |
-
decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
|
| 1518 |
-
if rag_match:
|
| 1519 |
-
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1520 |
if spam_result["is_spam"]:
|
| 1521 |
-
decision_factors.append(
|
| 1522 |
-
|
| 1523 |
-
|
| 1524 |
-
classification["
|
| 1525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1526 |
|
| 1527 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1532 |
|
| 1533 |
timeline["routed"] = get_now_ist()
|
| 1534 |
|
|
@@ -1536,10 +1124,8 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1536 |
if gemini_service and gemini_service._initialized:
|
| 1537 |
summary = gemini_service.get_summary(text)
|
| 1538 |
|
| 1539 |
-
# Convert priority to SLA
|
| 1540 |
-
|
| 1541 |
-
sla_hours = hours_map.get(classification["priority"], 72)
|
| 1542 |
-
sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
|
| 1543 |
|
| 1544 |
return TicketResponse(
|
| 1545 |
ticket_id=str(uuid.uuid4()), # Temporary ID
|
|
@@ -1552,22 +1138,18 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1552 |
entities=[EntityInfo(**e) for e in entities],
|
| 1553 |
duplicate_ticket=DuplicateInfo(**dup_result),
|
| 1554 |
confidence=classification["confidence"],
|
| 1555 |
-
needs_review=classification["confidence"] <
|
| 1556 |
reasoning=reasoning,
|
| 1557 |
decision_factors=decision_factors,
|
| 1558 |
image_description=gemini_analysis["image_description"],
|
| 1559 |
ocr_text=gemini_analysis["ocr_text"],
|
| 1560 |
-
highlights=
|
| 1561 |
timeline=timeline,
|
| 1562 |
env_metadata=env_metadata,
|
| 1563 |
-
|
| 1564 |
is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
|
| 1565 |
parent_ticket_id=dup_result.get("parent_ticket_id"),
|
| 1566 |
-
sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
|
| 1567 |
-
original_text=translation_ctx["original_text"],
|
| 1568 |
-
source_language=translation_ctx["source_language"],
|
| 1569 |
-
source_language_name=translation_ctx["source_language_name"],
|
| 1570 |
-
was_translated=translation_ctx["was_translated"],
|
| 1571 |
)
|
| 1572 |
|
| 1573 |
@app.post("/ai/analyze_stream")
|
|
@@ -1586,7 +1168,11 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1586 |
"model_version": "3.0.0-PRO",
|
| 1587 |
"api_endpoint": "/ai/analyze_stream"
|
| 1588 |
}
|
| 1589 |
-
timeline = {"received": get_now_ist()}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1590 |
|
| 1591 |
# 1. Reading
|
| 1592 |
yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
|
|
@@ -1602,15 +1188,6 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1602 |
|
| 1603 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1604 |
|
| 1605 |
-
# Spam / Phishing check (silent step — does not get its own SSE event)
|
| 1606 |
-
try:
|
| 1607 |
-
spam_result = spam_service.check(text, gemini_analysis.get("ocr_text", ""))
|
| 1608 |
-
except Exception:
|
| 1609 |
-
spam_result = {
|
| 1610 |
-
"is_spam": False, "risk_score": 0.0, "reasons": [],
|
| 1611 |
-
"suspicious_urls": [], "matched_keywords": [],
|
| 1612 |
-
}
|
| 1613 |
-
|
| 1614 |
# 2. NER
|
| 1615 |
yield f"data: {json.dumps({'step': 'Extracting technical entities', 'status': 'in_progress'})}\n\n"
|
| 1616 |
await asyncio.sleep(0.2)
|
|
@@ -1623,7 +1200,33 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1623 |
# 3. Classification
|
| 1624 |
yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
|
| 1625 |
await asyncio.sleep(0.2)
|
| 1626 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1627 |
timeline["ai_analyzed"] = get_now_ist()
|
| 1628 |
timeline["triaged"] = get_now_ist()
|
| 1629 |
|
|
@@ -1631,9 +1234,20 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1631 |
yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
|
| 1632 |
await asyncio.sleep(0.2)
|
| 1633 |
try:
|
| 1634 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1635 |
except Exception:
|
| 1636 |
-
dup_result = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1637 |
|
| 1638 |
# 5. RAG / Solutions
|
| 1639 |
yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
|
|
@@ -1649,7 +1263,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1649 |
pass
|
| 1650 |
|
| 1651 |
decision_factors = []
|
| 1652 |
-
if classification["confidence"] >
|
| 1653 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1654 |
if entities:
|
| 1655 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
@@ -1657,27 +1271,19 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1657 |
decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
|
| 1658 |
if rag_match:
|
| 1659 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1660 |
-
if spam_result["is_spam"]:
|
| 1661 |
-
decision_factors.append(
|
| 1662 |
-
f"Flagged as spam/phishing (risk {spam_result['risk_score']:.2f})"
|
| 1663 |
-
)
|
| 1664 |
-
classification["assigned_team"] = "Spam / Suspicious"
|
| 1665 |
-
classification["auto_resolve"] = False
|
| 1666 |
|
|
|
|
|
|
|
| 1667 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1668 |
if classification["auto_resolve"]:
|
| 1669 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
| 1670 |
-
if spam_result["is_spam"]:
|
| 1671 |
-
reasoning += " Ticket flagged as spam/phishing and quarantined from agent inbox."
|
| 1672 |
|
| 1673 |
timeline["routed"] = get_now_ist()
|
| 1674 |
|
| 1675 |
if gemini_service and gemini_service._initialized:
|
| 1676 |
summary = gemini_service.get_summary(text)
|
| 1677 |
|
| 1678 |
-
|
| 1679 |
-
sla_hours = hours_map.get(classification["priority"], 72)
|
| 1680 |
-
sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
|
| 1681 |
|
| 1682 |
ticket_response_dict = {
|
| 1683 |
"ticket_id": str(uuid.uuid4()),
|
|
@@ -1690,16 +1296,17 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1690 |
"entities": [e for e in entities],
|
| 1691 |
"duplicate_ticket": dup_result,
|
| 1692 |
"confidence": classification["confidence"],
|
| 1693 |
-
"needs_review": classification["confidence"] <
|
| 1694 |
"reasoning": reasoning,
|
| 1695 |
"decision_factors": decision_factors,
|
| 1696 |
"image_description": gemini_analysis["image_description"],
|
| 1697 |
"ocr_text": gemini_analysis["ocr_text"],
|
| 1698 |
-
"highlights":
|
| 1699 |
"timeline": timeline,
|
| 1700 |
"env_metadata": env_metadata,
|
| 1701 |
-
"
|
| 1702 |
-
"
|
|
|
|
| 1703 |
}
|
| 1704 |
|
| 1705 |
# 6. Final Result
|
|
@@ -1731,266 +1338,3 @@ async def analyze_ticket_v2(request: TicketRequest):
|
|
| 1731 |
}
|
| 1732 |
except Exception as e:
|
| 1733 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1734 |
-
|
| 1735 |
-
|
| 1736 |
-
# ---------------------------------------------------------------------------
|
| 1737 |
-
# SLA Engine Endpoints
|
| 1738 |
-
# ---------------------------------------------------------------------------
|
| 1739 |
-
|
| 1740 |
-
class SLAStatsResponse(BaseModel):
|
| 1741 |
-
total: int = 0
|
| 1742 |
-
active: int = 0
|
| 1743 |
-
breached: int = 0
|
| 1744 |
-
warning: int = 0
|
| 1745 |
-
met: int = 0
|
| 1746 |
-
breach_rate: float = 0.0
|
| 1747 |
-
by_priority: dict = {}
|
| 1748 |
-
|
| 1749 |
-
|
| 1750 |
-
@app.get("/sla/stats", response_model=SLAStatsResponse)
|
| 1751 |
-
async def sla_stats():
|
| 1752 |
-
"""Get aggregated SLA dashboard statistics across all tickets."""
|
| 1753 |
-
if not supabase:
|
| 1754 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1755 |
-
stats = await sla_engine.get_dashboard_stats()
|
| 1756 |
-
if "error" in stats:
|
| 1757 |
-
raise HTTPException(status_code=500, detail=stats["error"])
|
| 1758 |
-
return stats
|
| 1759 |
-
|
| 1760 |
-
|
| 1761 |
-
class SLATicketInfo(BaseModel):
|
| 1762 |
-
id: str
|
| 1763 |
-
ticket_id: str | None = None
|
| 1764 |
-
subject: str | None = None
|
| 1765 |
-
summary: str | None = None
|
| 1766 |
-
priority: str = "medium"
|
| 1767 |
-
status: str | None = None
|
| 1768 |
-
assigned_team: str | None = None
|
| 1769 |
-
sla_status: str = "active"
|
| 1770 |
-
escalation_level: int = 0
|
| 1771 |
-
remaining_seconds: int = 0
|
| 1772 |
-
created_at: str | None = None
|
| 1773 |
-
sla_breach_at: str | None = None
|
| 1774 |
-
sla_warning_at: str | None = None
|
| 1775 |
-
last_escalated_at: str | None = None
|
| 1776 |
-
|
| 1777 |
-
|
| 1778 |
-
@app.get("/sla/tickets")
|
| 1779 |
-
async def sla_tickets(
|
| 1780 |
-
status: str | None = None,
|
| 1781 |
-
priority: str | None = None,
|
| 1782 |
-
limit: int = 100,
|
| 1783 |
-
offset: int = 0,
|
| 1784 |
-
):
|
| 1785 |
-
"""
|
| 1786 |
-
List tickets with SLA status. Filter by sla_status and/or priority.
|
| 1787 |
-
"""
|
| 1788 |
-
if not supabase:
|
| 1789 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1790 |
-
|
| 1791 |
-
query = (
|
| 1792 |
-
supabase.table("tickets")
|
| 1793 |
-
.select("id, ticket_id, subject, summary, priority, status, assigned_team, sla_status, escalation_level, remaining_seconds, created_at, sla_breach_at, sla_warning_at, last_escalated_at")
|
| 1794 |
-
.order("created_at", desc=True)
|
| 1795 |
-
)
|
| 1796 |
-
|
| 1797 |
-
if status and status != "all":
|
| 1798 |
-
query = query.eq("sla_status", status)
|
| 1799 |
-
if priority and priority != "all":
|
| 1800 |
-
query = query.eq("priority", priority.capitalize())
|
| 1801 |
-
|
| 1802 |
-
query = query.range(offset, offset + limit - 1)
|
| 1803 |
-
res = query.execute()
|
| 1804 |
-
return {"tickets": res.data or [], "total": len(res.data or [])}
|
| 1805 |
-
|
| 1806 |
-
|
| 1807 |
-
class EscalationLogEntry(BaseModel):
|
| 1808 |
-
id: str
|
| 1809 |
-
ticket_id: str | None = None
|
| 1810 |
-
ticket_subject: str = ""
|
| 1811 |
-
priority: str = "medium"
|
| 1812 |
-
sla_status: str = ""
|
| 1813 |
-
escalation_level: int = 0
|
| 1814 |
-
remaining_seconds: int = 0
|
| 1815 |
-
assigned_team: str = ""
|
| 1816 |
-
notification_channels: list = []
|
| 1817 |
-
triggered_at: str | None = None
|
| 1818 |
-
resolved_at: str | None = None
|
| 1819 |
-
notes: str = ""
|
| 1820 |
-
|
| 1821 |
-
|
| 1822 |
-
@app.get("/sla/escalations")
|
| 1823 |
-
async def sla_escalations(limit: int = 50, offset: int = 0):
|
| 1824 |
-
"""Fetch escalation log history."""
|
| 1825 |
-
if not supabase:
|
| 1826 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1827 |
-
|
| 1828 |
-
try:
|
| 1829 |
-
res = (
|
| 1830 |
-
supabase.table("escalation_logs")
|
| 1831 |
-
.select("*")
|
| 1832 |
-
.order("triggered_at", desc=True)
|
| 1833 |
-
.range(offset, offset + limit - 1)
|
| 1834 |
-
.execute()
|
| 1835 |
-
)
|
| 1836 |
-
return {"escalations": res.data or [], "total": len(res.data or [])}
|
| 1837 |
-
except Exception as e:
|
| 1838 |
-
# Table might not exist yet
|
| 1839 |
-
print(f"[SLA] Escalation logs query failed: {e}")
|
| 1840 |
-
return {"escalations": [], "total": 0}
|
| 1841 |
-
|
| 1842 |
-
|
| 1843 |
-
class SLAPolicyInfo(BaseModel):
|
| 1844 |
-
id: str
|
| 1845 |
-
priority: str
|
| 1846 |
-
max_hours: int
|
| 1847 |
-
warning_pct: float
|
| 1848 |
-
auto_escalate: bool
|
| 1849 |
-
l2_after_minutes: int
|
| 1850 |
-
l3_after_minutes: int
|
| 1851 |
-
|
| 1852 |
-
|
| 1853 |
-
@app.get("/sla/policies")
|
| 1854 |
-
async def sla_policies():
|
| 1855 |
-
"""Get configured SLA policies."""
|
| 1856 |
-
if not supabase:
|
| 1857 |
-
# Return defaults from code
|
| 1858 |
-
policies = []
|
| 1859 |
-
policy_source = sla_engine.SLA_POLICIES if hasattr(sla_engine, "SLA_POLICIES") else {}
|
| 1860 |
-
for pri, cfg in policy_source.items():
|
| 1861 |
-
policies.append({
|
| 1862 |
-
"priority": pri,
|
| 1863 |
-
"max_hours": cfg["max_hours"],
|
| 1864 |
-
"warning_pct": cfg["warning_pct"],
|
| 1865 |
-
"auto_escalate": cfg.get("auto_escalate_on_breach", False),
|
| 1866 |
-
"l2_after_minutes": cfg.get("l2_escalation_mins", 0),
|
| 1867 |
-
"l3_after_minutes": cfg.get("l3_escalation_mins", 0),
|
| 1868 |
-
})
|
| 1869 |
-
return {"policies": policies}
|
| 1870 |
-
|
| 1871 |
-
try:
|
| 1872 |
-
res = supabase.table("sla_policies").select("*").execute()
|
| 1873 |
-
return {"policies": res.data or []}
|
| 1874 |
-
except Exception as e:
|
| 1875 |
-
print(f"[SLA] Policies query failed: {e}")
|
| 1876 |
-
return {"policies": []}
|
| 1877 |
-
|
| 1878 |
-
|
| 1879 |
-
@app.post("/sla/check")
|
| 1880 |
-
async def trigger_sla_check():
|
| 1881 |
-
"""Manually trigger an SLA evaluation cycle (admin)."""
|
| 1882 |
-
if not supabase:
|
| 1883 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1884 |
-
|
| 1885 |
-
asyncio.create_task(sla_engine.check_all_active_tickets())
|
| 1886 |
-
return {"status": "triggered", "message": "SLA check cycle started in background"}
|
| 1887 |
-
|
| 1888 |
-
|
| 1889 |
-
# ---------------------------------------------------------------------------
|
| 1890 |
-
# Semantic Duplicate Detection Endpoints
|
| 1891 |
-
# ---------------------------------------------------------------------------
|
| 1892 |
-
|
| 1893 |
-
@app.post("/ai/check_duplicate")
|
| 1894 |
-
async def check_duplicate_endpoint(
|
| 1895 |
-
body: TicketRequest,
|
| 1896 |
-
company_id: str | None = None,
|
| 1897 |
-
):
|
| 1898 |
-
"""
|
| 1899 |
-
Check a ticket text for potential duplicates using semantic vector search.
|
| 1900 |
-
Returns top candidates with similarity scores.
|
| 1901 |
-
"""
|
| 1902 |
-
text = (body.text or "").strip()
|
| 1903 |
-
if not text:
|
| 1904 |
-
raise HTTPException(status_code=400, detail="No text provided")
|
| 1905 |
-
|
| 1906 |
-
threshold = body.duplicate_sensitivity if hasattr(body, 'duplicate_sensitivity') else None
|
| 1907 |
-
result = await semantic_dupe_service.check_duplicate(
|
| 1908 |
-
text=text,
|
| 1909 |
-
company_id=company_id or body.company,
|
| 1910 |
-
threshold=threshold,
|
| 1911 |
-
)
|
| 1912 |
-
return result
|
| 1913 |
-
|
| 1914 |
-
|
| 1915 |
-
@app.post("/ai/reindex_embeddings")
|
| 1916 |
-
async def reindex_embeddings():
|
| 1917 |
-
"""Re-generate vector embeddings for all tickets."""
|
| 1918 |
-
result = await semantic_dupe_service.reindex_all()
|
| 1919 |
-
return result
|
| 1920 |
-
|
| 1921 |
-
|
| 1922 |
-
@app.get("/system/settings")
|
| 1923 |
-
async def get_system_settings_endpoint():
|
| 1924 |
-
"""Fetch all system settings."""
|
| 1925 |
-
_logger = logging.getLogger(__name__)
|
| 1926 |
-
if not supabase:
|
| 1927 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1928 |
-
try:
|
| 1929 |
-
res = supabase.table("system_settings").select("*").execute()
|
| 1930 |
-
settings = {}
|
| 1931 |
-
for row in res.data or []:
|
| 1932 |
-
settings[row["key"]] = row["value"]
|
| 1933 |
-
return settings
|
| 1934 |
-
except Exception as e:
|
| 1935 |
-
_logger.warning(f"[SETTINGS] Query failed: {e}")
|
| 1936 |
-
return {}
|
| 1937 |
-
|
| 1938 |
-
|
| 1939 |
-
@app.patch("/system/settings")
|
| 1940 |
-
async def update_system_settings(body: dict):
|
| 1941 |
-
"""Update a specific system setting."""
|
| 1942 |
-
if not supabase:
|
| 1943 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1944 |
-
key = body.get("key")
|
| 1945 |
-
value = body.get("value")
|
| 1946 |
-
if not key or value is None:
|
| 1947 |
-
raise HTTPException(status_code=400, detail="key and value required")
|
| 1948 |
-
try:
|
| 1949 |
-
supabase.table("system_settings").upsert({
|
| 1950 |
-
"key": key,
|
| 1951 |
-
"value": value,
|
| 1952 |
-
"updated_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 1953 |
-
}).execute()
|
| 1954 |
-
return {"status": "updated", "key": key}
|
| 1955 |
-
except Exception as e:
|
| 1956 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 1957 |
-
|
| 1958 |
-
|
| 1959 |
-
@app.get("/sla/tickets/{ticket_id}")
|
| 1960 |
-
async def sla_ticket_detail(ticket_id: str):
|
| 1961 |
-
"""Get detailed SLA info for a specific ticket."""
|
| 1962 |
-
if not supabase:
|
| 1963 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1964 |
-
|
| 1965 |
-
# Fetch ticket
|
| 1966 |
-
res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
|
| 1967 |
-
if not res.data:
|
| 1968 |
-
raise HTTPException(status_code=404, detail="Ticket not found")
|
| 1969 |
-
|
| 1970 |
-
ticket = res.data
|
| 1971 |
-
result = sla_engine.evaluate_ticket(ticket)
|
| 1972 |
-
|
| 1973 |
-
# Fetch escalation history for this ticket
|
| 1974 |
-
try:
|
| 1975 |
-
esc_res = (
|
| 1976 |
-
supabase.table("escalation_logs")
|
| 1977 |
-
.select("*")
|
| 1978 |
-
.eq("ticket_id", ticket_id)
|
| 1979 |
-
.order("triggered_at", desc=True)
|
| 1980 |
-
.execute()
|
| 1981 |
-
)
|
| 1982 |
-
escalations = esc_res.data or []
|
| 1983 |
-
except Exception:
|
| 1984 |
-
escalations = []
|
| 1985 |
-
|
| 1986 |
-
return {
|
| 1987 |
-
"ticket": ticket,
|
| 1988 |
-
"sla_evaluation": result,
|
| 1989 |
-
"escalations": escalations,
|
| 1990 |
-
}
|
| 1991 |
-
|
| 1992 |
-
|
| 1993 |
-
@app.get("/metrics")
|
| 1994 |
-
async def metrics():
|
| 1995 |
-
"""Prometheus scrape endpoint — exposes AI inference latency, request counts, and tokens."""
|
| 1996 |
-
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
|
|
|
| 8 |
import sys
|
| 9 |
import uuid
|
| 10 |
import json
|
|
|
|
| 11 |
import datetime
|
| 12 |
import traceback
|
| 13 |
import warnings
|
|
|
|
| 19 |
warnings.filterwarnings("ignore", message="'pin_memory'")
|
| 20 |
|
| 21 |
# HF Rebuild Trigger: 2026-03-08-2030
|
| 22 |
+
from fastapi import FastAPI, Depends, Header, HTTPException, Request, Response
|
| 23 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 24 |
+
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
|
| 25 |
from slowapi.util import get_remote_address
|
| 26 |
from slowapi.errors import RateLimitExceeded
|
| 27 |
from fastapi.middleware.cors import CORSMiddleware
|
| 28 |
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
|
|
| 29 |
from fastapi.encoders import jsonable_encoder
|
| 30 |
import asyncio
|
| 31 |
from pathlib import Path
|
|
|
|
| 36 |
env_path = Path(__file__).parent / '.env'
|
| 37 |
load_dotenv(dotenv_path=env_path)
|
| 38 |
|
| 39 |
+
# Apply database encryption for PII fields
|
| 40 |
+
try:
|
| 41 |
+
from backend.auth.crypto import apply_db_encryption_patch
|
| 42 |
+
apply_db_encryption_patch()
|
| 43 |
+
except Exception as e:
|
| 44 |
+
print(f"[WARNING] Database encryption patch initialization failed: {e}")
|
| 45 |
|
|
|
|
|
|
|
| 46 |
|
| 47 |
# Initialize Supabase Client (Service Role for backend bypass)
|
| 48 |
try:
|
|
|
|
| 53 |
print("[ERROR] SUPABASE_URL or SUPABASE_SERVICE_KEY not set in backend/.env")
|
| 54 |
supabase = None
|
| 55 |
else:
|
| 56 |
+
supabase = create_client(url, key)
|
|
|
|
| 57 |
except (ImportError, Exception) as e:
|
| 58 |
print(f"[WARNING] Supabase initialization failed: {e}")
|
| 59 |
supabase = None
|
|
|
|
| 65 |
from backend.services.classifier_service import ClassifierService
|
| 66 |
from backend.services.classifier_v2 import classifier_v2
|
| 67 |
from backend.services.classifier_v3 import classifier_v3 # V3 Power Model
|
|
|
|
|
|
|
| 68 |
from backend.services.ner_service import NERService
|
| 69 |
from backend.services.duplicate_service import DuplicateService
|
|
|
|
| 70 |
from backend.services.rag_service import RagService
|
| 71 |
+
from backend.services.sla_service import (
|
| 72 |
+
calculate_sla_breach_at,
|
| 73 |
+
calculate_sla_response_at,
|
| 74 |
+
classify_sla_status,
|
| 75 |
+
load as load_sla_service,
|
| 76 |
+
run_sla_escalation_loop,
|
| 77 |
+
)
|
| 78 |
+
from backend.services.spam_detector_service import analyze_spam_phishing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
# ---------------------------------------------------------------------------
|
|
|
|
| 124 |
duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
|
| 125 |
duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
|
| 126 |
return duplicate_result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
class TicketRequest(BaseModel):
|
| 128 |
text: str
|
| 129 |
image_base64: str = ""
|
| 130 |
image_text: str = "" # Keep for backward compatibility
|
| 131 |
user_id: str | None = None
|
| 132 |
company: str | None = None
|
| 133 |
+
company_id: str | None = None
|
| 134 |
image_url: str | None = None
|
| 135 |
confidence_threshold: float = 0.20
|
| 136 |
duplicate_sensitivity: float = 0.85
|
|
|
|
| 150 |
image_url: str | None = None
|
| 151 |
company: str | None = None
|
| 152 |
company_id: str | None = None
|
| 153 |
+
description_vector: list[float] | None = None
|
| 154 |
+
is_potential_duplicate: bool = False
|
| 155 |
+
parent_ticket_id: str | None = None
|
| 156 |
+
sla_response_due_at: str | None = None
|
| 157 |
sla_breach_at: str
|
| 158 |
sla_status: str | None = None
|
| 159 |
escalation_level: int = 0
|
|
|
|
| 169 |
class DuplicateInfo(BaseModel):
|
| 170 |
is_duplicate: bool
|
| 171 |
duplicate_ticket_id: str | None = None
|
| 172 |
+
parent_ticket_id: str | None = None
|
| 173 |
+
is_potential_duplicate: bool = False
|
| 174 |
similarity: float = 0.0
|
| 175 |
|
| 176 |
|
|
|
|
| 180 |
confidence: float
|
| 181 |
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
class TicketResponse(BaseModel):
|
| 184 |
id: str | int | None = None
|
| 185 |
ticket_id: str | None = None
|
|
|
|
| 192 |
entities: list[EntityInfo]
|
| 193 |
duplicate_ticket: DuplicateInfo
|
| 194 |
confidence: float
|
| 195 |
+
is_potential_duplicate: bool = False
|
| 196 |
+
parent_ticket_id: str | None = None
|
| 197 |
needs_review: bool = False
|
| 198 |
reasoning: str = ""
|
| 199 |
decision_factors: list[str] = []
|
|
|
|
| 203 |
timeline: dict = {} # Map of step_name: timestamp
|
| 204 |
env_metadata: dict = {} # IP, Hostname, Browser/OS
|
| 205 |
sla_breach_at: str | None = None
|
| 206 |
+
spam_analysis: dict | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
version: str = "2.1.0-Neural-Diagnostic"
|
| 208 |
|
| 209 |
|
|
|
|
| 231 |
timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
|
| 232 |
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
# --- In-Memory Database (to be replaced with SQL later) ---
|
| 235 |
TICKETS_DB: list[TicketRecord] = []
|
| 236 |
|
|
|
|
| 253 |
ner_service = NERService()
|
| 254 |
duplicate_service = DuplicateService()
|
| 255 |
rag_service = RagService()
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
try:
|
| 258 |
from backend.services.gemini_service import GeminiService
|
|
|
|
| 266 |
except ImportError:
|
| 267 |
ocr_service = None
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
# ---------------------------------------------------------------------------
|
| 271 |
# Lifespan (startup / shutdown)
|
|
|
|
| 274 |
async def lifespan(app: FastAPI):
|
| 275 |
"""Load all models at startup."""
|
| 276 |
print("[Startup] Loading AI models ...")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
try:
|
| 278 |
classifier_service.load()
|
| 279 |
+
except Exception as e:
|
| 280 |
print(f"[WARNING] Classifier not loaded: {e}")
|
| 281 |
try:
|
| 282 |
ner_service.load()
|
| 283 |
+
except Exception as e:
|
| 284 |
print(f"[WARNING] NER not loaded: {e}")
|
| 285 |
try:
|
| 286 |
duplicate_service.load()
|
|
|
|
| 290 |
rag_service.load()
|
| 291 |
except Exception as e:
|
| 292 |
print(f"[WARNING] RAG service not loaded: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
if gemini_service:
|
| 295 |
print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
|
| 296 |
else:
|
| 297 |
print("[Startup] Gemini Service: NOT LOADED (Import failed)")
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
print("[Startup] Classifier V2 Shadow: Ready.")
|
|
|
|
| 300 |
print("[Startup] Ready.")
|
| 301 |
+
# Strict health checks: fail loudly when core model assets are unavailable.
|
| 302 |
+
# Set ALLOW_DEGRADED_STARTUP=1 to permit degraded startup for local/dev convenience.
|
| 303 |
+
try:
|
| 304 |
+
strict_mode = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") != "1"
|
| 305 |
+
except Exception:
|
| 306 |
+
strict_mode = True
|
| 307 |
|
| 308 |
+
classifier_loaded_flag = getattr(classifier_service, "_loaded", False)
|
| 309 |
+
ner_loaded_flag = getattr(ner_service, "_loaded", False)
|
|
|
|
| 310 |
|
| 311 |
+
if strict_mode and not classifier_loaded_flag:
|
| 312 |
+
raise RuntimeError("[Startup-FATAL] Classifier assets not loaded. Set ALLOW_DEGRADED_STARTUP=1 to bypass.")
|
| 313 |
|
| 314 |
+
sla_task = None
|
|
|
|
| 315 |
try:
|
| 316 |
+
if supabase and os.environ.get("SLA_ESCALATION_ENABLED", "true").lower() == "true":
|
| 317 |
+
notification_router = None
|
| 318 |
+
try:
|
| 319 |
+
from backend.services.notification_routing import load as load_notification_router
|
| 320 |
+
notification_router = load_notification_router()
|
| 321 |
+
except Exception as e:
|
| 322 |
+
print(f"[WARNING] Notification router not loaded for SLA service: {e}")
|
| 323 |
+
sla_service = load_sla_service(supabase, notification_router)
|
| 324 |
+
interval = int(os.environ.get("SLA_ESCALATION_INTERVAL_SECONDS", "300"))
|
| 325 |
+
sla_task = asyncio.create_task(run_sla_escalation_loop(sla_service, interval_seconds=interval))
|
| 326 |
+
print(f"[Startup] SLA escalation loop enabled ({interval}s interval).")
|
| 327 |
+
|
| 328 |
+
yield
|
| 329 |
+
finally:
|
| 330 |
+
if sla_task:
|
| 331 |
+
sla_task.cancel()
|
| 332 |
+
try:
|
| 333 |
+
await sla_task
|
| 334 |
+
except asyncio.CancelledError:
|
| 335 |
+
pass
|
| 336 |
+
print("[Shutdown] Cleaning up ...")
|
| 337 |
|
| 338 |
|
| 339 |
# ---------------------------------------------------------------------------
|
|
|
|
| 364 |
allow_headers=["*"],
|
| 365 |
)
|
| 366 |
|
|
|
|
|
|
|
| 367 |
|
| 368 |
# ---------------------------------------------------------------------------
|
| 369 |
# Root & Health check
|
|
|
|
| 450 |
"""
|
| 451 |
|
| 452 |
|
| 453 |
+
async def verify_metrics_token(x_metrics_token: str | None = Header(default=None)):
|
| 454 |
+
expected_token = os.environ.get("METRICS_TOKEN")
|
| 455 |
+
if expected_token and x_metrics_token != expected_token:
|
| 456 |
+
raise HTTPException(status_code=403, detail="Forbidden")
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
@app.get("/metrics", dependencies=[Depends(verify_metrics_token)])
|
| 460 |
+
def metrics():
|
| 461 |
+
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
@app.get("/health", response_model=HealthResponse)
|
| 465 |
async def health_check():
|
| 466 |
return HealthResponse(
|
|
|
|
| 473 |
@app.get("/ready", response_model=ReadinessResponse)
|
| 474 |
async def readiness_check():
|
| 475 |
require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
|
| 476 |
+
allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
|
| 477 |
+
|
| 478 |
checks = {
|
| 479 |
"api": True,
|
| 480 |
"classifier_loaded": classifier_service._loaded,
|
| 481 |
"ner_loaded": ner_service._loaded,
|
| 482 |
+
"duplicate_index_loaded": duplicate_service.is_available(),
|
| 483 |
+
"rag_loaded": rag_service.is_available(),
|
| 484 |
}
|
| 485 |
if require_supabase:
|
| 486 |
checks["supabase_configured"] = supabase is not None
|
| 487 |
|
| 488 |
+
# In degraded mode, duplicate and RAG services are optional
|
| 489 |
+
if allow_degraded:
|
| 490 |
+
required_checks = {k: v for k, v in checks.items() if k not in ["duplicate_index_loaded", "rag_loaded"]}
|
| 491 |
+
all_required_pass = all(required_checks.values())
|
| 492 |
+
|
| 493 |
+
if all_required_pass:
|
| 494 |
+
return ReadinessResponse(status="ready", checks=checks)
|
| 495 |
+
else:
|
| 496 |
+
# Strict mode: all checks must pass
|
| 497 |
+
if all(checks.values()):
|
| 498 |
+
return ReadinessResponse(status="ready", checks=checks)
|
| 499 |
|
| 500 |
return JSONResponse(
|
| 501 |
status_code=503,
|
|
|
|
| 623 |
# ---------------------------------------------------------------------------
|
| 624 |
# Ticket operations (Now via Supabase)
|
| 625 |
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
@app.get("/tickets")
|
| 627 |
+
async def get_tickets(company_id: str | None = None):
|
|
|
|
|
|
|
|
|
|
| 628 |
"""Fetch persistent tickets from Supabase."""
|
| 629 |
if not supabase:
|
| 630 |
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
|
|
|
|
|
|
|
|
|
| 631 |
|
| 632 |
query = supabase.table("tickets").select("*").order("created_at", desc=True)
|
| 633 |
+
if company_id:
|
| 634 |
+
query = query.eq("company_id", company_id)
|
| 635 |
|
| 636 |
res = query.execute()
|
| 637 |
return res.data
|
| 638 |
|
| 639 |
+
@app.get("/tickets/search")
|
| 640 |
+
async def search_tickets(q: str | None = None, company_id: str | None = None, limit: int = 50, offset: int = 0):
|
| 641 |
+
"""Search tickets using tenant-safe full-text search."""
|
| 642 |
+
if not supabase:
|
| 643 |
+
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 644 |
+
|
| 645 |
+
if not q:
|
| 646 |
+
raise HTTPException(status_code=400, detail="Search query is required")
|
| 647 |
+
if not company_id:
|
| 648 |
+
raise HTTPException(status_code=400, detail="company_id is required for tenant-safe search")
|
| 649 |
+
|
| 650 |
+
try:
|
| 651 |
+
result = supabase.rpc(
|
| 652 |
+
"search_tickets",
|
| 653 |
+
{
|
| 654 |
+
"query_text": q,
|
| 655 |
+
"company_id": company_id,
|
| 656 |
+
"limit_rows": limit,
|
| 657 |
+
"offset_rows": offset,
|
| 658 |
+
},
|
| 659 |
+
).execute()
|
| 660 |
+
return result.data or []
|
| 661 |
+
except Exception as e:
|
| 662 |
+
raise HTTPException(status_code=500, detail=f"Search failed: {e}")
|
| 663 |
+
|
| 664 |
@app.post("/tickets/save")
|
| 665 |
async def save_ticket(request_body: TicketSaveRequest):
|
| 666 |
"""
|
|
|
|
| 671 |
raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
|
| 672 |
|
| 673 |
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
try:
|
| 675 |
+
final_data = request_body.dict()
|
| 676 |
+
|
| 677 |
+
# Resolve tenant linkage from user profile with authorization validation.
|
| 678 |
+
profile = {}
|
| 679 |
+
if request_body.user_id:
|
| 680 |
+
try:
|
| 681 |
+
profile_res = (
|
| 682 |
+
supabase.table("profiles")
|
| 683 |
+
.select("company_id, company")
|
| 684 |
+
.eq("id", request_body.user_id)
|
| 685 |
+
.single()
|
| 686 |
+
.execute()
|
| 687 |
+
)
|
| 688 |
+
profile = profile_res.data or {}
|
| 689 |
+
if not profile:
|
| 690 |
+
raise HTTPException(status_code=404, detail="User profile not found")
|
| 691 |
+
|
| 692 |
+
# SELF-HEALING: If company_id is null in database but company name exists, resolve it!
|
| 693 |
+
if not profile.get("company_id") and profile.get("company"):
|
| 694 |
+
try:
|
| 695 |
+
comp_name = profile.get("company").strip()
|
| 696 |
+
comp_res = (
|
| 697 |
+
supabase.table("companies")
|
| 698 |
+
.select("id")
|
| 699 |
+
.ilike("name", comp_name)
|
| 700 |
+
.execute()
|
| 701 |
+
)
|
| 702 |
+
if comp_res.data:
|
| 703 |
+
resolved_company_id = comp_res.data[0]["id"]
|
| 704 |
+
# Backfill the profile table in real-time
|
| 705 |
+
supabase.table("profiles").update({"company_id": resolved_company_id}).eq("id", request_body.user_id).execute()
|
| 706 |
+
profile["company_id"] = resolved_company_id
|
| 707 |
+
logger.info(f"[SELF-HEALING] Backfilled company_id={resolved_company_id} for user={request_body.user_id}")
|
| 708 |
+
except Exception as healing_err:
|
| 709 |
+
logger.warning(f"[SELF-HEALING WARNING] Failed to backfill company_id: {healing_err}")
|
| 710 |
+
except HTTPException:
|
| 711 |
+
raise
|
| 712 |
+
except Exception as profile_error:
|
| 713 |
+
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 714 |
+
logger.error(f"Tenant resolution error for user {user_hash}: {profile_error}")
|
| 715 |
+
raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
|
| 716 |
+
|
| 717 |
+
# Validate tenant consistency and authorization.
|
| 718 |
+
profile_company_id = profile.get("company_id")
|
| 719 |
+
if final_data.get("company_id"):
|
| 720 |
+
# User provided company_id: verify it matches their profile.
|
| 721 |
+
if profile_company_id and final_data["company_id"] != profile_company_id:
|
| 722 |
+
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 723 |
+
logger.warning(f"Tenant mismatch: user {user_hash} attempted {final_data['company_id']}, assigned to {profile_company_id}")
|
| 724 |
+
raise HTTPException(status_code=403, detail="User not authorized for this tenant")
|
| 725 |
+
elif profile_company_id:
|
| 726 |
+
# Backfill company_id from profile.
|
| 727 |
+
final_data["company_id"] = profile_company_id
|
| 728 |
+
elif request_body.user_id:
|
| 729 |
+
# User has no tenant assignment.
|
| 730 |
+
raise HTTPException(status_code=400, detail="User has no tenant assignment")
|
| 731 |
+
|
| 732 |
# Backfill company name if missing.
|
| 733 |
if not final_data.get("company") and profile.get("company"):
|
| 734 |
final_data["company"] = profile["company"]
|
|
|
|
| 741 |
final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
|
| 742 |
final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
|
| 743 |
|
| 744 |
+
import hashlib
|
| 745 |
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 746 |
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 747 |
|
| 748 |
duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
|
| 749 |
+
duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85)
|
| 750 |
+
duplicate_result = {
|
| 751 |
+
"is_duplicate": False,
|
| 752 |
+
"duplicate_ticket_id": None,
|
| 753 |
+
"parent_ticket_id": None,
|
| 754 |
+
"is_potential_duplicate": False,
|
| 755 |
+
"similarity": 0.0,
|
| 756 |
+
}
|
| 757 |
|
| 758 |
+
if duplicate_text:
|
| 759 |
+
duplicate_result = detect_semantic_duplicate(
|
| 760 |
+
duplicate_text,
|
| 761 |
+
company_id=final_data.get("company_id"),
|
| 762 |
+
threshold=duplicate_threshold,
|
| 763 |
+
)
|
| 764 |
+
final_data["description_vector"] = duplicate_service.generate_embedding(duplicate_text)
|
| 765 |
+
else:
|
| 766 |
+
final_data["description_vector"] = None
|
| 767 |
|
| 768 |
+
final_data["is_potential_duplicate"] = duplicate_result.get("is_potential_duplicate", False)
|
| 769 |
+
final_data["parent_ticket_id"] = duplicate_result.get("parent_ticket_id")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
|
| 771 |
# --- Sanitize payload to only include valid Supabase DB columns ---
|
| 772 |
# Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
|
|
|
|
| 774 |
VALID_TICKET_COLUMNS = {
|
| 775 |
"user_id", "subject", "description", "category", "subcategory",
|
| 776 |
"priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
|
| 777 |
+
"confidence", "image_url", "company", "company_id", "sla_breach_at", "metadata",
|
|
|
|
| 778 |
}
|
| 779 |
# Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
|
| 780 |
existing_metadata = final_data.get("metadata") or {}
|
| 781 |
extra_keys = (
|
| 782 |
"entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
|
| 783 |
+
"is_potential_duplicate", "parent_ticket_id", "sla_response_due_at", "sla_status", "escalation_level",
|
| 784 |
+
"spam_analysis"
|
| 785 |
)
|
| 786 |
for extra_key in extra_keys:
|
| 787 |
if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
|
|
|
|
| 798 |
|
| 799 |
ticket_id = res.data[0]["id"]
|
| 800 |
|
| 801 |
+
duplicate_indexed = True
|
| 802 |
+
duplicate_index_warning = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
if duplicate_text:
|
| 804 |
try:
|
|
|
|
| 805 |
duplicate_service.add_ticket(str(ticket_id), duplicate_text)
|
|
|
|
|
|
|
| 806 |
except Exception as index_error:
|
| 807 |
+
duplicate_indexed = False
|
| 808 |
+
duplicate_index_warning = "Duplicate index update failed."
|
| 809 |
+
print(f"[WARNING] {duplicate_index_warning} ticket_id={ticket_id} error={index_error}")
|
| 810 |
+
else:
|
| 811 |
+
duplicate_indexed = False
|
| 812 |
+
duplicate_index_warning = "Duplicate index update skipped: no description or subject text was provided."
|
| 813 |
+
print(f"[WARNING] {duplicate_index_warning}")
|
| 814 |
|
| 815 |
# Add initial system diagnostic message
|
| 816 |
msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
|
|
|
|
| 828 |
response = {
|
| 829 |
"status": "success",
|
| 830 |
"ticket_id": ticket_id,
|
| 831 |
+
"duplicate_indexed": duplicate_indexed,
|
| 832 |
+
"is_potential_duplicate": final_data["is_potential_duplicate"],
|
| 833 |
+
"parent_ticket_id": final_data["parent_ticket_id"],
|
| 834 |
}
|
| 835 |
+
if duplicate_index_warning:
|
| 836 |
+
response["duplicate_index_warning"] = duplicate_index_warning
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
return response
|
| 838 |
|
| 839 |
except Exception as e:
|
| 840 |
traceback.print_exc()
|
| 841 |
raise HTTPException(status_code=500, detail=str(e))
|
| 842 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 843 |
@app.get("/tickets/{ticket_id}")
|
| 844 |
+
async def get_ticket_by_id(ticket_id: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
"""Fetch single persistent ticket."""
|
| 846 |
if not supabase:
|
| 847 |
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 848 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
|
| 850 |
if not res.data:
|
| 851 |
raise HTTPException(status_code=404, detail="Ticket not found")
|
|
|
|
|
|
|
| 852 |
return res.data
|
| 853 |
|
| 854 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
@app.post("/tickets", response_model=TicketRecord)
|
| 856 |
async def create_ticket(ticket: TicketRecord):
|
| 857 |
"""Save a new ticket into the system."""
|
|
|
|
| 890 |
Main endpoint for analyzing a new ticket using the cascade of local AI models.
|
| 891 |
"""
|
| 892 |
text = request_body.text
|
| 893 |
+
|
| 894 |
+
settings = get_system_settings(request_body.company_id)
|
| 895 |
+
confidence_threshold = settings["ai_confidence_threshold"]
|
| 896 |
+
duplicate_sensitivity = settings["duplicate_sensitivity"]
|
| 897 |
+
enable_auto_resolve = settings["enable_auto_resolve"]
|
| 898 |
|
| 899 |
# Grab client metadata
|
| 900 |
client_ip = request.client.host if request.client else "unknown"
|
|
|
|
| 916 |
text = f"{text} {local_ocr_text}".strip()
|
| 917 |
print(f"[AI] OCR added {len(local_ocr_text)} chars to context.")
|
| 918 |
|
| 919 |
+
# Initalize Timeline
|
| 920 |
+
return await analyze_only(request_body)
|
|
|
|
| 921 |
|
| 922 |
@app.post("/ai/analyze")
|
| 923 |
async def analyze_only(request_body: TicketRequest):
|
|
|
|
| 927 |
and duplicate check before committing to a ticket creation.
|
| 928 |
"""
|
| 929 |
text = request_body.text
|
|
|
|
|
|
|
| 930 |
print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
|
| 931 |
settings = get_system_settings(request_body.company)
|
| 932 |
confidence_threshold = settings["ai_confidence_threshold"]
|
|
|
|
| 968 |
highlights=[],
|
| 969 |
timeline={"received": _dt.datetime.utcnow().isoformat() + "Z"},
|
| 970 |
env_metadata={},
|
| 971 |
+
is_potential_duplicate=False,
|
| 972 |
+
parent_ticket_id=None,
|
| 973 |
sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
)
|
| 975 |
|
| 976 |
# --- Context & Environment ---
|
|
|
|
| 1002 |
|
| 1003 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1004 |
|
| 1005 |
+
# --- Spam & Phishing Detection Layer ---
|
| 1006 |
+
spam_result = analyze_spam_phishing(text, gemini_analysis.get("ocr_text", ""))
|
| 1007 |
+
|
| 1008 |
+
# --- Classification ---
|
| 1009 |
try:
|
| 1010 |
+
classification_v3_res = classifier_v3.predict(text)
|
| 1011 |
+
if "error" in classification_v3_res:
|
| 1012 |
+
# Fallback to V1
|
| 1013 |
+
classification = classifier_service.predict(text)
|
| 1014 |
+
else:
|
| 1015 |
+
# Parse V3 output
|
| 1016 |
+
cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
|
| 1017 |
+
sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
|
| 1018 |
+
pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
|
| 1019 |
+
conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
|
| 1020 |
+
|
| 1021 |
+
from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
|
| 1022 |
+
assigned_team = TEAM_MAP.get(cat, "General Support")
|
| 1023 |
+
auto_resolve = sub in AUTO_RESOLVE_SUBS
|
| 1024 |
+
|
| 1025 |
+
classification = {
|
| 1026 |
+
"category": cat,
|
| 1027 |
+
"subcategory": sub,
|
| 1028 |
+
"priority": pri,
|
| 1029 |
+
"auto_resolve": auto_resolve,
|
| 1030 |
+
"assigned_team": assigned_team,
|
| 1031 |
+
"confidence": float(conf)
|
| 1032 |
+
}
|
| 1033 |
except Exception as e:
|
| 1034 |
+
traceback.print_exc()
|
| 1035 |
+
classification = {
|
| 1036 |
+
"category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
|
| 1037 |
+
"auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
|
| 1038 |
}
|
| 1039 |
|
| 1040 |
+
# Apply Spam overrides if spam/phishing is detected
|
| 1041 |
+
if spam_result["is_spam"]:
|
| 1042 |
+
classification["category"] = "Spam"
|
| 1043 |
+
if spam_result["risk_level"] == "high":
|
| 1044 |
+
classification["subcategory"] = "Suspicious Phishing"
|
| 1045 |
+
elif spam_result["risk_level"] == "medium":
|
| 1046 |
+
classification["subcategory"] = "Spam Inquiry"
|
| 1047 |
+
else:
|
| 1048 |
+
classification["subcategory"] = "Low-Risk Spam"
|
| 1049 |
+
classification["priority"] = "Low"
|
| 1050 |
+
classification["assigned_team"] = "Security Unit"
|
| 1051 |
+
classification["auto_resolve"] = False
|
| 1052 |
+
classification["confidence"] = max(classification["confidence"], 0.95)
|
| 1053 |
|
| 1054 |
timeline["ai_analyzed"] = get_now_ist()
|
| 1055 |
timeline["triaged"] = get_now_ist()
|
|
|
|
| 1063 |
timeline["metadata_harvested"] = get_now_ist()
|
| 1064 |
|
| 1065 |
# --- Duplicate detection ---
|
| 1066 |
+
duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
|
| 1067 |
try:
|
| 1068 |
+
dup_result = detect_semantic_duplicate(
|
| 1069 |
+
text,
|
| 1070 |
+
company_id=request_body.company_id,
|
| 1071 |
+
threshold=duplicate_threshold,
|
| 1072 |
+
)
|
| 1073 |
except Exception:
|
| 1074 |
+
dup_result = {
|
| 1075 |
+
"is_duplicate": False,
|
| 1076 |
+
"duplicate_ticket_id": None,
|
| 1077 |
+
"parent_ticket_id": None,
|
| 1078 |
+
"is_potential_duplicate": False,
|
| 1079 |
+
"similarity": 0.0,
|
| 1080 |
+
}
|
| 1081 |
|
| 1082 |
# --- RAG Knowledge Base Check ---
|
| 1083 |
rag_match = None
|
|
|
|
| 1093 |
|
| 1094 |
# --- Reasoning ---
|
| 1095 |
decision_factors = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1096 |
if spam_result["is_spam"]:
|
| 1097 |
+
decision_factors.append(f"Spam/Phishing detected (Risk: {spam_result['risk_level'].upper()})")
|
| 1098 |
+
reasoning = f"Flagged as potential spam/phishing. Reasons: {', '.join(spam_result['reasons'])}"
|
| 1099 |
+
else:
|
| 1100 |
+
if classification["confidence"] > confidence_threshold:
|
| 1101 |
+
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1102 |
+
if entities:
|
| 1103 |
+
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
| 1104 |
+
if dup_result["is_duplicate"]:
|
| 1105 |
+
decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
|
| 1106 |
+
if rag_match:
|
| 1107 |
+
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1108 |
|
| 1109 |
+
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1110 |
+
if (
|
| 1111 |
+
enable_auto_resolve
|
| 1112 |
+
and classification["confidence"] >= confidence_threshold
|
| 1113 |
+
and classification["auto_resolve"]
|
| 1114 |
+
):
|
| 1115 |
+
classification["auto_resolve"] = True
|
| 1116 |
+
else:
|
| 1117 |
+
classification["auto_resolve"] = False
|
| 1118 |
+
if classification["auto_resolve"]:
|
| 1119 |
+
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
| 1120 |
|
| 1121 |
timeline["routed"] = get_now_ist()
|
| 1122 |
|
|
|
|
| 1124 |
if gemini_service and gemini_service._initialized:
|
| 1125 |
summary = gemini_service.get_summary(text)
|
| 1126 |
|
| 1127 |
+
# Convert priority to the SLA resolution target timestamp for preview.
|
| 1128 |
+
sla_breach_dt = calculate_sla_breach_at(classification["priority"])
|
|
|
|
|
|
|
| 1129 |
|
| 1130 |
return TicketResponse(
|
| 1131 |
ticket_id=str(uuid.uuid4()), # Temporary ID
|
|
|
|
| 1138 |
entities=[EntityInfo(**e) for e in entities],
|
| 1139 |
duplicate_ticket=DuplicateInfo(**dup_result),
|
| 1140 |
confidence=classification["confidence"],
|
| 1141 |
+
needs_review=classification["confidence"] < confidence_threshold,
|
| 1142 |
reasoning=reasoning,
|
| 1143 |
decision_factors=decision_factors,
|
| 1144 |
image_description=gemini_analysis["image_description"],
|
| 1145 |
ocr_text=gemini_analysis["ocr_text"],
|
| 1146 |
+
highlights=entities, # Use entities as highlights for now
|
| 1147 |
timeline=timeline,
|
| 1148 |
env_metadata=env_metadata,
|
| 1149 |
+
spam_analysis=spam_result,
|
| 1150 |
is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
|
| 1151 |
parent_ticket_id=dup_result.get("parent_ticket_id"),
|
| 1152 |
+
sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1153 |
)
|
| 1154 |
|
| 1155 |
@app.post("/ai/analyze_stream")
|
|
|
|
| 1168 |
"model_version": "3.0.0-PRO",
|
| 1169 |
"api_endpoint": "/ai/analyze_stream"
|
| 1170 |
}
|
| 1171 |
+
timeline = {"received": get_now_ist()}
|
| 1172 |
+
settings = get_system_settings(request_body.company_id)
|
| 1173 |
+
confidence_threshold = settings["ai_confidence_threshold"]
|
| 1174 |
+
duplicate_sensitivity = settings["duplicate_sensitivity"]
|
| 1175 |
+
enable_auto_resolve = settings["enable_auto_resolve"]
|
| 1176 |
|
| 1177 |
# 1. Reading
|
| 1178 |
yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
|
|
|
|
| 1188 |
|
| 1189 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1191 |
# 2. NER
|
| 1192 |
yield f"data: {json.dumps({'step': 'Extracting technical entities', 'status': 'in_progress'})}\n\n"
|
| 1193 |
await asyncio.sleep(0.2)
|
|
|
|
| 1200 |
# 3. Classification
|
| 1201 |
yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
|
| 1202 |
await asyncio.sleep(0.2)
|
| 1203 |
+
try:
|
| 1204 |
+
classification_v3_res = classifier_v3.predict(text)
|
| 1205 |
+
if "error" in classification_v3_res:
|
| 1206 |
+
classification = classifier_service.predict(text)
|
| 1207 |
+
else:
|
| 1208 |
+
cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
|
| 1209 |
+
sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
|
| 1210 |
+
pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
|
| 1211 |
+
conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
|
| 1212 |
+
|
| 1213 |
+
from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
|
| 1214 |
+
assigned_team = TEAM_MAP.get(cat, "General Support")
|
| 1215 |
+
auto_resolve = sub in AUTO_RESOLVE_SUBS
|
| 1216 |
+
|
| 1217 |
+
classification = {
|
| 1218 |
+
"category": cat,
|
| 1219 |
+
"subcategory": sub,
|
| 1220 |
+
"priority": pri,
|
| 1221 |
+
"auto_resolve": auto_resolve,
|
| 1222 |
+
"assigned_team": assigned_team,
|
| 1223 |
+
"confidence": float(conf)
|
| 1224 |
+
}
|
| 1225 |
+
except Exception as e:
|
| 1226 |
+
classification = {
|
| 1227 |
+
"category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
|
| 1228 |
+
"auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
|
| 1229 |
+
}
|
| 1230 |
timeline["ai_analyzed"] = get_now_ist()
|
| 1231 |
timeline["triaged"] = get_now_ist()
|
| 1232 |
|
|
|
|
| 1234 |
yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
|
| 1235 |
await asyncio.sleep(0.2)
|
| 1236 |
try:
|
| 1237 |
+
duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
|
| 1238 |
+
dup_result = detect_semantic_duplicate(
|
| 1239 |
+
text,
|
| 1240 |
+
company_id=request_body.company_id,
|
| 1241 |
+
threshold=duplicate_threshold,
|
| 1242 |
+
)
|
| 1243 |
except Exception:
|
| 1244 |
+
dup_result = {
|
| 1245 |
+
"is_duplicate": False,
|
| 1246 |
+
"duplicate_ticket_id": None,
|
| 1247 |
+
"parent_ticket_id": None,
|
| 1248 |
+
"is_potential_duplicate": False,
|
| 1249 |
+
"similarity": 0.0,
|
| 1250 |
+
}
|
| 1251 |
|
| 1252 |
# 5. RAG / Solutions
|
| 1253 |
yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
|
|
|
|
| 1263 |
pass
|
| 1264 |
|
| 1265 |
decision_factors = []
|
| 1266 |
+
if classification["confidence"] > confidence_threshold:
|
| 1267 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1268 |
if entities:
|
| 1269 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
|
|
| 1271 |
decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
|
| 1272 |
if rag_match:
|
| 1273 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1274 |
|
| 1275 |
+
if not enable_auto_resolve:
|
| 1276 |
+
classification["auto_resolve"] = False
|
| 1277 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1278 |
if classification["auto_resolve"]:
|
| 1279 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
|
|
|
|
|
|
| 1280 |
|
| 1281 |
timeline["routed"] = get_now_ist()
|
| 1282 |
|
| 1283 |
if gemini_service and gemini_service._initialized:
|
| 1284 |
summary = gemini_service.get_summary(text)
|
| 1285 |
|
| 1286 |
+
sla_breach_dt = calculate_sla_breach_at(classification["priority"])
|
|
|
|
|
|
|
| 1287 |
|
| 1288 |
ticket_response_dict = {
|
| 1289 |
"ticket_id": str(uuid.uuid4()),
|
|
|
|
| 1296 |
"entities": [e for e in entities],
|
| 1297 |
"duplicate_ticket": dup_result,
|
| 1298 |
"confidence": classification["confidence"],
|
| 1299 |
+
"needs_review": classification["confidence"] < confidence_threshold,
|
| 1300 |
"reasoning": reasoning,
|
| 1301 |
"decision_factors": decision_factors,
|
| 1302 |
"image_description": gemini_analysis["image_description"],
|
| 1303 |
"ocr_text": gemini_analysis["ocr_text"],
|
| 1304 |
+
"highlights": entities,
|
| 1305 |
"timeline": timeline,
|
| 1306 |
"env_metadata": env_metadata,
|
| 1307 |
+
"is_potential_duplicate": dup_result.get("is_potential_duplicate", False),
|
| 1308 |
+
"parent_ticket_id": dup_result.get("parent_ticket_id"),
|
| 1309 |
+
"sla_breach_at": sla_breach_dt.isoformat().replace("+00:00", "Z")
|
| 1310 |
}
|
| 1311 |
|
| 1312 |
# 6. Final Result
|
|
|
|
| 1338 |
}
|
| 1339 |
except Exception as e:
|
| 1340 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/requirements.txt
CHANGED
|
@@ -4,8 +4,6 @@ datasets>=2.14.0
|
|
| 4 |
pandas>=2.0.0
|
| 5 |
scikit-learn>=1.3.0
|
| 6 |
sentence-transformers>=2.2.0
|
| 7 |
-
onnxruntime>=1.17.0
|
| 8 |
-
tokenizers>=0.15.0
|
| 9 |
fastapi>=0.104.0
|
| 10 |
uvicorn>=0.24.0
|
| 11 |
openpyxl>=3.1.0
|
|
@@ -17,11 +15,4 @@ easyocr
|
|
| 17 |
slowapi>=0.1.9
|
| 18 |
supabase==2.22.4
|
| 19 |
storage3==2.22.4
|
| 20 |
-
|
| 21 |
-
pytest
|
| 22 |
-
pytest-asyncio
|
| 23 |
-
httpx
|
| 24 |
-
cryptography>=42.0.0
|
| 25 |
-
websockets>=12.0
|
| 26 |
-
prometheus-client>=0.19.0
|
| 27 |
-
|
|
|
|
| 4 |
pandas>=2.0.0
|
| 5 |
scikit-learn>=1.3.0
|
| 6 |
sentence-transformers>=2.2.0
|
|
|
|
|
|
|
| 7 |
fastapi>=0.104.0
|
| 8 |
uvicorn>=0.24.0
|
| 9 |
openpyxl>=3.1.0
|
|
|
|
| 15 |
slowapi>=0.1.9
|
| 16 |
supabase==2.22.4
|
| 17 |
storage3==2.22.4
|
| 18 |
+
prometheus-client>=0.17.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/services/classifier_service.py
CHANGED
|
@@ -6,32 +6,29 @@ Priority and other fields are derived from the category mapping.
|
|
| 6 |
|
| 7 |
import os
|
| 8 |
import json
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "classifier")
|
| 22 |
-
DEVICE = torch.device("cuda" if torch
|
| 23 |
MAX_LEN = 128
|
| 24 |
|
| 25 |
-
try:
|
| 26 |
-
from backend.services.metrics_service import (
|
| 27 |
-
CLASSIFIER_LATENCY,
|
| 28 |
-
CLASSIFIER_REQUESTS,
|
| 29 |
-
CLASSIFIER_TOKENS,
|
| 30 |
-
)
|
| 31 |
-
_METRICS_ENABLED = True
|
| 32 |
-
except Exception:
|
| 33 |
-
_METRICS_ENABLED = False
|
| 34 |
-
|
| 35 |
# Priority mapping based on sub-category severity
|
| 36 |
PRIORITY_MAP = {
|
| 37 |
"Blue Screen": "Critical", "Overheating": "Critical", "Data Loss": "Critical",
|
|
@@ -75,19 +72,26 @@ class ClassifierService:
|
|
| 75 |
if self._loaded:
|
| 76 |
return
|
| 77 |
|
| 78 |
-
if not _HAS_TORCH:
|
| 79 |
-
# Degraded environment: ML runtime not available. Delay failure until predict is called.
|
| 80 |
-
print("[INFO] ML runtime not available; classifier will remain unloaded until dependencies are installed.")
|
| 81 |
-
return
|
| 82 |
-
|
| 83 |
abs_dir = os.path.abspath(SAVE_DIR)
|
|
|
|
| 84 |
|
| 85 |
-
if not os.path.exists(
|
| 86 |
raise FileNotFoundError(
|
| 87 |
f"Classifier model not found at {abs_dir}. "
|
| 88 |
"Please ensure model files are present."
|
| 89 |
)
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
# Load label mappings
|
| 92 |
with open(os.path.join(abs_dir, "id2label.json"), "r") as f:
|
| 93 |
self.id2label = json.load(f)
|
|
@@ -109,76 +113,74 @@ class ClassifierService:
|
|
| 109 |
"""
|
| 110 |
Predict category, subcategory, priority, auto_resolve, assigned_team, and confidence.
|
| 111 |
"""
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
encoding = self.tokenizer(
|
| 115 |
-
text,
|
| 116 |
-
truncation=True,
|
| 117 |
-
padding="max_length",
|
| 118 |
-
max_length=MAX_LEN,
|
| 119 |
-
return_tensors="pt",
|
| 120 |
-
)
|
| 121 |
-
input_ids = encoding["input_ids"].to(DEVICE)
|
| 122 |
-
attention_mask = encoding["attention_mask"].to(DEVICE)
|
| 123 |
-
|
| 124 |
-
import time
|
| 125 |
-
_t0 = time.perf_counter()
|
| 126 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
with torch.no_grad():
|
| 128 |
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
|
| 129 |
logits = outputs.logits
|
| 130 |
probs = F.softmax(logits, dim=1)
|
| 131 |
confidence, pred_idx = torch.max(probs, dim=1)
|
| 132 |
-
|
| 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 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
"
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
}
|
|
|
|
| 6 |
|
| 7 |
import os
|
| 8 |
import json
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn.functional as F
|
| 11 |
+
from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification
|
| 12 |
+
import time
|
| 13 |
+
from prometheus_client import Counter, Histogram
|
| 14 |
+
|
| 15 |
+
# Prometheus metrics for tracking model performance
|
| 16 |
+
MODEL_PREDICTIONS_TOTAL = Counter(
|
| 17 |
+
"model_predictions_total",
|
| 18 |
+
"Total count of DistilBERT predictions",
|
| 19 |
+
["status"]
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
MODEL_PREDICTION_LATENCY = Histogram(
|
| 23 |
+
"model_prediction_latency_seconds",
|
| 24 |
+
"Latency of DistilBERT prediction in seconds",
|
| 25 |
+
buckets=(0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 10.0)
|
| 26 |
+
)
|
| 27 |
|
| 28 |
SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "classifier")
|
| 29 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 30 |
MAX_LEN = 128
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# Priority mapping based on sub-category severity
|
| 33 |
PRIORITY_MAP = {
|
| 34 |
"Blue Screen": "Critical", "Overheating": "Critical", "Data Loss": "Critical",
|
|
|
|
| 72 |
if self._loaded:
|
| 73 |
return
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
abs_dir = os.path.abspath(SAVE_DIR)
|
| 76 |
+
safetensors_path = os.path.join(abs_dir, "model.safetensors")
|
| 77 |
|
| 78 |
+
if not os.path.exists(safetensors_path):
|
| 79 |
raise FileNotFoundError(
|
| 80 |
f"Classifier model not found at {abs_dir}. "
|
| 81 |
"Please ensure model files are present."
|
| 82 |
)
|
| 83 |
|
| 84 |
+
with open(safetensors_path, "rb") as f:
|
| 85 |
+
header = f.read(512)
|
| 86 |
+
if (
|
| 87 |
+
b"version https://git-lfs.github.com/spec" in header
|
| 88 |
+
or b"oid sha256:" in header
|
| 89 |
+
):
|
| 90 |
+
raise FileNotFoundError(
|
| 91 |
+
f"Classifier model at {abs_dir} is a Git LFS placeholder, not the actual model. "
|
| 92 |
+
"Please pull the LFS assets."
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
# Load label mappings
|
| 96 |
with open(os.path.join(abs_dir, "id2label.json"), "r") as f:
|
| 97 |
self.id2label = json.load(f)
|
|
|
|
| 113 |
"""
|
| 114 |
Predict category, subcategory, priority, auto_resolve, assigned_team, and confidence.
|
| 115 |
"""
|
| 116 |
+
start_time = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
try:
|
| 118 |
+
self.load()
|
| 119 |
+
|
| 120 |
+
encoding = self.tokenizer(
|
| 121 |
+
text,
|
| 122 |
+
truncation=True,
|
| 123 |
+
padding="max_length",
|
| 124 |
+
max_length=MAX_LEN,
|
| 125 |
+
return_tensors="pt",
|
| 126 |
+
)
|
| 127 |
+
input_ids = encoding["input_ids"].to(DEVICE)
|
| 128 |
+
attention_mask = encoding["attention_mask"].to(DEVICE)
|
| 129 |
+
|
| 130 |
with torch.no_grad():
|
| 131 |
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
|
| 132 |
logits = outputs.logits
|
| 133 |
probs = F.softmax(logits, dim=1)
|
| 134 |
confidence, pred_idx = torch.max(probs, dim=1)
|
| 135 |
+
|
| 136 |
+
pred_idx = pred_idx.item()
|
| 137 |
+
confidence = round(confidence.item(), 4)
|
| 138 |
+
|
| 139 |
+
# Decode the combined label "Category | SubCategory"
|
| 140 |
+
combined_label = self.id2label.get(str(pred_idx), "Unknown | Unknown")
|
| 141 |
+
parts = combined_label.split(" | ", 1)
|
| 142 |
+
category = parts[0].strip() if len(parts) > 0 else "Unknown"
|
| 143 |
+
subcategory = parts[1].strip() if len(parts) > 1 else "Unknown"
|
| 144 |
+
|
| 145 |
+
# Derive priority
|
| 146 |
+
priority = PRIORITY_MAP.get(subcategory, "Medium")
|
| 147 |
+
|
| 148 |
+
# Derive assigned team
|
| 149 |
+
assigned_team = TEAM_MAP.get(category, "General Support")
|
| 150 |
+
|
| 151 |
+
# Derive auto_resolve
|
| 152 |
+
auto_resolve = subcategory in AUTO_RESOLVE_SUBS
|
| 153 |
+
|
| 154 |
+
# --- Regex Override Layer (Boost for Technical Keywords) ---
|
| 155 |
+
tech_keywords = {
|
| 156 |
+
"Network": ["IP address", "hostname", "connection", "network", "bandwidth", "DNS", "firewall", "VPN", "Connectivity", "Latency", "Routing", "Spikes"],
|
| 157 |
+
"Software": ["crash", "load", "website", "application", "error", "bug", "failing", "software", "SQL", "Cluster", "Database", "Production", "Latency"],
|
| 158 |
+
"Access": ["login", "password", "access", "authentication", "account", "permission", "MFA", "OAuth"]
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
lower_text = text.lower()
|
| 162 |
+
for cat, keywords in tech_keywords.items():
|
| 163 |
+
if any(k.lower() in lower_text for k in keywords):
|
| 164 |
+
# If current prediction is generic, or we have a high-value technical keyword
|
| 165 |
+
if category == "General" or confidence < 0.9:
|
| 166 |
+
category = cat
|
| 167 |
+
assigned_team = TEAM_MAP.get(cat, "General Support")
|
| 168 |
+
# Boost confidence significantly for verified technical signals
|
| 169 |
+
confidence = max(confidence, 0.92)
|
| 170 |
+
break
|
| 171 |
+
|
| 172 |
+
MODEL_PREDICTIONS_TOTAL.labels(status="success").inc()
|
| 173 |
+
return {
|
| 174 |
+
"category": category,
|
| 175 |
+
"subcategory": subcategory,
|
| 176 |
+
"priority": priority,
|
| 177 |
+
"auto_resolve": auto_resolve,
|
| 178 |
+
"assigned_team": assigned_team,
|
| 179 |
+
"confidence": confidence,
|
| 180 |
+
}
|
| 181 |
+
except Exception as e:
|
| 182 |
+
MODEL_PREDICTIONS_TOTAL.labels(status="failure").inc()
|
| 183 |
+
raise e
|
| 184 |
+
finally:
|
| 185 |
+
duration = time.time() - start_time
|
| 186 |
+
MODEL_PREDICTION_LATENCY.observe(duration)
|
|
|
backend/services/classifier_v2.py
CHANGED
|
@@ -1,109 +1,89 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import pickle
|
| 3 |
import json
|
| 4 |
-
|
| 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 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
self.heads[name] = nn.Linear(hidden, n_labels)
|
| 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 |
-
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 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
self.tokenizer = None
|
| 104 |
-
self.label_encoders = {}
|
| 105 |
|
| 106 |
-
|
| 107 |
-
return {"error": "V2 model not available in this environment"}
|
| 108 |
|
| 109 |
-
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
import pickle
|
| 5 |
import json
|
| 6 |
+
from transformers import DistilBertTokenizerFast, DistilBertModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# Paths
|
| 9 |
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 10 |
MODEL_DIR = os.path.join(BASE_DIR, "models", "classifier-v2")
|
| 11 |
|
| 12 |
# We must use the exact same class definition as trainer_v2
|
| 13 |
+
class MultiOutputClassifierV2(nn.Module):
|
| 14 |
+
def __init__(self, num_labels_per_output: dict):
|
| 15 |
+
super().__init__()
|
| 16 |
+
self.bert = DistilBertModel.from_pretrained("distilbert-base-uncased")
|
| 17 |
+
hidden = self.bert.config.hidden_size
|
| 18 |
+
self.dropout = nn.Dropout(0.2)
|
| 19 |
+
self.heads = nn.ModuleDict()
|
| 20 |
+
for name, n_labels in num_labels_per_output.items():
|
| 21 |
+
self.heads[name] = nn.Linear(hidden, n_labels)
|
|
|
|
| 22 |
|
| 23 |
+
def forward(self, input_ids, attention_mask):
|
| 24 |
+
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
|
| 25 |
+
cls_output = outputs.last_hidden_state[:, 0]
|
| 26 |
+
cls_output = self.dropout(cls_output)
|
| 27 |
+
logits = {name: head(cls_output) for name, head in self.heads.items()}
|
| 28 |
+
return logits
|
| 29 |
|
| 30 |
+
class ClassifierServiceV2:
|
| 31 |
+
def __init__(self):
|
| 32 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 33 |
+
|
| 34 |
+
# 1. Load Config
|
| 35 |
+
config_path = os.path.join(MODEL_DIR, "model_config.json")
|
| 36 |
+
if not os.path.exists(config_path):
|
| 37 |
+
self.model = None
|
| 38 |
+
print(f"[WARN] V2 Model config not found at {config_path}")
|
| 39 |
+
return
|
| 40 |
|
| 41 |
+
with open(config_path, "r") as f:
|
| 42 |
+
self.num_labels = json.load(f)
|
| 43 |
|
| 44 |
+
# 2. Load Encoders
|
| 45 |
+
with open(os.path.join(MODEL_DIR, "label_encoders.pkl"), "rb") as f:
|
| 46 |
+
self.label_encoders = pickle.load(f)
|
| 47 |
|
| 48 |
+
# 3. Load Model
|
| 49 |
+
self.model = MultiOutputClassifierV2(self.num_labels).to(self.device)
|
| 50 |
+
model_path = os.path.join(MODEL_DIR, "model.pt")
|
| 51 |
+
self.model.load_state_dict(torch.load(model_path, map_location=self.device))
|
| 52 |
+
self.model.eval()
|
| 53 |
|
| 54 |
+
# 4. Load Tokenizer
|
| 55 |
+
self.tokenizer = DistilBertTokenizerFast.from_pretrained(MODEL_DIR)
|
| 56 |
+
print("[SUCCESS] Classifier Service V2 (Shadow) Loaded Successfully.")
|
| 57 |
|
| 58 |
+
def predict(self, text: str):
|
| 59 |
+
if self.model is None:
|
| 60 |
+
return {"error": "V2 Model not initialized"}
|
| 61 |
|
| 62 |
+
inputs = self.tokenizer(
|
| 63 |
+
text,
|
| 64 |
+
return_tensors="pt",
|
| 65 |
+
truncation=True,
|
| 66 |
+
padding=True,
|
| 67 |
+
max_length=256 # V2 uses 256
|
| 68 |
+
).to(self.device)
|
| 69 |
|
| 70 |
+
with torch.no_grad():
|
| 71 |
+
logits = self.model(inputs["input_ids"], inputs["attention_mask"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
results = {}
|
| 74 |
+
for col, le in self.label_encoders.items():
|
| 75 |
+
probs = torch.softmax(logits[col], dim=1)
|
| 76 |
+
conf, pred_idx = torch.max(probs, dim=1)
|
| 77 |
+
results[col] = {
|
| 78 |
+
"prediction": le.inverse_transform([pred_idx.item()])[0],
|
| 79 |
+
"confidence": float(conf.item())
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
# Map V2 'Priority' (capitalized) to generic response
|
| 83 |
+
if "Priority" in results:
|
| 84 |
+
results["priority"] = results.pop("Priority")
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
return results
|
|
|
|
| 87 |
|
| 88 |
+
# Singleton instance
|
| 89 |
+
classifier_v2 = ClassifierServiceV2()
|
backend/services/classifier_v3.py
CHANGED
|
@@ -1,95 +1,75 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import pickle
|
| 3 |
import json
|
| 4 |
-
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 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 |
-
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 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def predict(self, text: str):
|
| 93 |
-
return {"error": "V3 model not available in this environment"}
|
| 94 |
|
| 95 |
-
|
|
|
|
| 1 |
import os
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
import pickle
|
| 5 |
import json
|
| 6 |
+
from transformers import BertTokenizerFast, BertModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# Paths
|
| 9 |
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 10 |
MODEL_DIR = os.path.join(BASE_DIR, "models", "classifier-v3")
|
| 11 |
|
| 12 |
+
class MultiOutputClassifierV3(nn.Module):
|
| 13 |
+
def __init__(self, num_labels_per_output: dict):
|
| 14 |
+
super().__init__()
|
| 15 |
+
self.bert = BertModel.from_pretrained("bert-base-uncased")
|
| 16 |
+
hidden = self.bert.config.hidden_size
|
| 17 |
+
self.dropout = nn.Dropout(0.3)
|
| 18 |
+
self.heads = nn.ModuleDict()
|
| 19 |
+
for name, n_labels in num_labels_per_output.items():
|
| 20 |
+
self.heads[name] = nn.Sequential(
|
| 21 |
+
nn.Linear(hidden, 256),
|
| 22 |
+
nn.ReLU(),
|
| 23 |
+
nn.Dropout(0.1),
|
| 24 |
+
nn.Linear(256, n_labels)
|
| 25 |
+
)
|
|
|
|
| 26 |
|
| 27 |
+
def forward(self, input_ids, attention_mask):
|
| 28 |
+
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
|
| 29 |
+
pooled_output = outputs.pooler_output
|
| 30 |
+
pooled_output = self.dropout(pooled_output)
|
| 31 |
+
logits = {name: head(pooled_output) for name, head in self.heads.items()}
|
| 32 |
+
return logits
|
| 33 |
|
| 34 |
+
class ClassifierServiceV3:
|
| 35 |
+
def __init__(self):
|
| 36 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 37 |
+
self.model = None
|
| 38 |
+
|
| 39 |
+
config_path = os.path.join(MODEL_DIR, "model_config.json")
|
| 40 |
+
if not os.path.exists(config_path):
|
| 41 |
+
print(f"[V3 Service] Model not found yet at {MODEL_DIR}")
|
| 42 |
+
return
|
| 43 |
|
| 44 |
+
with open(config_path, "r") as f:
|
| 45 |
+
self.num_labels = json.load(f)
|
| 46 |
|
| 47 |
+
with open(os.path.join(MODEL_DIR, "label_encoders.pkl"), "rb") as f:
|
| 48 |
+
self.label_encoders = pickle.load(f)
|
| 49 |
|
| 50 |
+
self.model = MultiOutputClassifierV3(self.num_labels).to(self.device)
|
| 51 |
+
self.model.load_state_dict(torch.load(os.path.join(MODEL_DIR, "model.pt"), map_location=self.device))
|
| 52 |
+
self.model.eval()
|
| 53 |
|
| 54 |
+
self.tokenizer = BertTokenizerFast.from_pretrained(MODEL_DIR)
|
| 55 |
+
print("[INFO] Classifier Service V3 (Power Model) Loaded.")
|
| 56 |
|
| 57 |
+
def predict(self, text: str):
|
| 58 |
+
if self.model is None: return {"error": "V3 Model not loaded"}
|
| 59 |
+
inputs = self.tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=256).to(self.device)
|
| 60 |
+
with torch.no_grad():
|
| 61 |
+
logits = self.model(inputs["input_ids"], inputs["attention_mask"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
results = {}
|
| 64 |
+
for col, le in self.label_encoders.items():
|
| 65 |
+
probs = torch.softmax(logits[col], dim=1)
|
| 66 |
+
conf, pred_idx = torch.max(probs, dim=1)
|
| 67 |
+
results[col] = {
|
| 68 |
+
"prediction": le.inverse_transform([pred_idx.item()])[0],
|
| 69 |
+
"confidence": float(conf.item())
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if "Priority" in results: results["priority"] = results.pop("Priority")
|
| 73 |
+
return results
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
classifier_v3 = ClassifierServiceV3()
|
backend/services/duplicate_service.py
CHANGED
|
@@ -7,13 +7,7 @@ import uuid
|
|
| 7 |
import os
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
-
|
| 11 |
-
from sentence_transformers import SentenceTransformer, util
|
| 12 |
-
_HAS_SENTENCE = True
|
| 13 |
-
except Exception: # pragma: no cover - optional runtime dependency
|
| 14 |
-
SentenceTransformer = None
|
| 15 |
-
util = None
|
| 16 |
-
_HAS_SENTENCE = False
|
| 17 |
|
| 18 |
SIMILARITY_THRESHOLD = 0.70
|
| 19 |
|
|
@@ -38,17 +32,6 @@ class DuplicateService:
|
|
| 38 |
return
|
| 39 |
|
| 40 |
print("[DuplicateService] Loading model...")
|
| 41 |
-
if not _HAS_SENTENCE:
|
| 42 |
-
allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
|
| 43 |
-
self._load_failed = True
|
| 44 |
-
print("[DuplicateService] sentence-transformers not installed")
|
| 45 |
-
if allow_degraded:
|
| 46 |
-
print("[DuplicateService] DEGRADED: Continuing without model (ALLOW_DEGRADED_STARTUP=1)")
|
| 47 |
-
self.model = None
|
| 48 |
-
self._loaded = False
|
| 49 |
-
return
|
| 50 |
-
else:
|
| 51 |
-
raise ImportError("sentence-transformers is required for DuplicateService")
|
| 52 |
try:
|
| 53 |
# Check if a local model path is provided
|
| 54 |
model_path = os.environ.get("SENTENCE_TRANSFORMER_MODEL_PATH")
|
|
@@ -118,20 +101,12 @@ class DuplicateService:
|
|
| 118 |
|
| 119 |
def generate_embedding(self, text: str) -> list[float] | None:
|
| 120 |
"""Generate a 384-d embedding for the provided ticket text."""
|
| 121 |
-
from backend.services.redis_cache import redis_cache
|
| 122 |
-
|
| 123 |
-
cached = redis_cache.get_embedding(text)
|
| 124 |
-
if cached is not None:
|
| 125 |
-
return cached
|
| 126 |
-
|
| 127 |
self.load()
|
| 128 |
if not self.is_available():
|
| 129 |
return None
|
| 130 |
|
| 131 |
embedding = self.model.encode(text, convert_to_tensor=False, normalize_embeddings=True)
|
| 132 |
-
|
| 133 |
-
redis_cache.set_embedding(text, values)
|
| 134 |
-
return values
|
| 135 |
|
| 136 |
def _build_result(
|
| 137 |
self,
|
|
|
|
| 7 |
import os
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
+
from sentence_transformers import SentenceTransformer, util
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
SIMILARITY_THRESHOLD = 0.70
|
| 13 |
|
|
|
|
| 32 |
return
|
| 33 |
|
| 34 |
print("[DuplicateService] Loading model...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
try:
|
| 36 |
# Check if a local model path is provided
|
| 37 |
model_path = os.environ.get("SENTENCE_TRANSFORMER_MODEL_PATH")
|
|
|
|
| 101 |
|
| 102 |
def generate_embedding(self, text: str) -> list[float] | None:
|
| 103 |
"""Generate a 384-d embedding for the provided ticket text."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
self.load()
|
| 105 |
if not self.is_available():
|
| 106 |
return None
|
| 107 |
|
| 108 |
embedding = self.model.encode(text, convert_to_tensor=False, normalize_embeddings=True)
|
| 109 |
+
return [float(value) for value in embedding.tolist()]
|
|
|
|
|
|
|
| 110 |
|
| 111 |
def _build_result(
|
| 112 |
self,
|
backend/services/gemini_service.py
CHANGED
|
@@ -2,20 +2,11 @@ import os
|
|
| 2 |
import base64
|
| 3 |
import io
|
| 4 |
import re
|
| 5 |
-
import
|
|
|
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
-
try:
|
| 10 |
-
from PIL import Image
|
| 11 |
-
from google import genai
|
| 12 |
-
_HAS_GEMINI_DEPS = True
|
| 13 |
-
except ImportError:
|
| 14 |
-
Image = None
|
| 15 |
-
genai = None
|
| 16 |
-
_HAS_GEMINI_DEPS = False
|
| 17 |
-
|
| 18 |
-
|
| 19 |
# Load environment variables from backend/.env
|
| 20 |
env_path = Path(__file__).parent.parent / '.env'
|
| 21 |
load_dotenv(dotenv_path=env_path)
|
|
@@ -26,7 +17,7 @@ class GeminiService:
|
|
| 26 |
self._initialized = False
|
| 27 |
self.model_name = 'gemini-2.5-flash'
|
| 28 |
|
| 29 |
-
if self.api_key
|
| 30 |
try:
|
| 31 |
self.client = genai.Client(api_key=self.api_key)
|
| 32 |
self._initialized = True
|
|
@@ -34,18 +25,15 @@ class GeminiService:
|
|
| 34 |
except Exception as e:
|
| 35 |
print(f"[GeminiService] Initialization Error: {e}")
|
| 36 |
else:
|
| 37 |
-
|
| 38 |
-
print("[GeminiService] WARNING: PIL or google-genai package is not installed. Gemini service is disabled.")
|
| 39 |
-
else:
|
| 40 |
-
print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
|
| 41 |
|
| 42 |
-
def analyze_image(self, image_base64: str
|
| 43 |
"""
|
| 44 |
Perform OCR and image analysis using Gemini logic.
|
| 45 |
"""
|
| 46 |
-
if not self._initialized
|
| 47 |
return {
|
| 48 |
-
"image_description": "[Gemini
|
| 49 |
"ocr_text": "",
|
| 50 |
"detected_problem": ""
|
| 51 |
}
|
|
@@ -58,10 +46,6 @@ class GeminiService:
|
|
| 58 |
|
| 59 |
prompt = (
|
| 60 |
"Analyze this screenshot from a user reporting a technical issue. "
|
| 61 |
-
)
|
| 62 |
-
if context_text:
|
| 63 |
-
prompt += f"Context/description provided by user: '{context_text}'\n"
|
| 64 |
-
prompt += (
|
| 65 |
"1. Provide a concise description of what is shown in the image. "
|
| 66 |
"2. Perform OCR and extract any error messages or key text. "
|
| 67 |
"3. Identify the main technical problem depicted. "
|
|
@@ -238,65 +222,3 @@ class GeminiService:
|
|
| 238 |
except Exception as e:
|
| 239 |
print(f"[GeminiService] Bug Analysis Error: {e}")
|
| 240 |
return f"Diagnostic analysis failed: {str(e)}"
|
| 241 |
-
|
| 242 |
-
def detect_language(self, text: str) -> dict:
|
| 243 |
-
"""
|
| 244 |
-
Detect language for the given text. Returns ISO-ish language code and English language name.
|
| 245 |
-
"""
|
| 246 |
-
if not text or not text.strip():
|
| 247 |
-
return {"code": "en", "name": "English"}
|
| 248 |
-
if not self._initialized:
|
| 249 |
-
return {"code": "en", "name": "English"}
|
| 250 |
-
|
| 251 |
-
try:
|
| 252 |
-
prompt = (
|
| 253 |
-
"Detect the natural language of the following user message. "
|
| 254 |
-
"Return strict JSON only with keys: code, name. "
|
| 255 |
-
"Example: {\"code\":\"es\",\"name\":\"Spanish\"}.\n\n"
|
| 256 |
-
f"Text:\n{text}"
|
| 257 |
-
)
|
| 258 |
-
response = self.client.models.generate_content(
|
| 259 |
-
model=self.model_name,
|
| 260 |
-
contents=prompt
|
| 261 |
-
)
|
| 262 |
-
raw = (response.text or "").strip()
|
| 263 |
-
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
| 264 |
-
parsed = json.loads(match.group(0) if match else raw)
|
| 265 |
-
code = str(parsed.get("code", "en")).lower()
|
| 266 |
-
name = str(parsed.get("name", "English"))
|
| 267 |
-
if not code:
|
| 268 |
-
code = "en"
|
| 269 |
-
if not name:
|
| 270 |
-
name = "English"
|
| 271 |
-
return {"code": code, "name": name}
|
| 272 |
-
except Exception as e:
|
| 273 |
-
print(f"[GeminiService] Language detection error: {e}")
|
| 274 |
-
return {"code": "en", "name": "English"}
|
| 275 |
-
|
| 276 |
-
def translate_to_english(self, text: str, source_language: str | None = None) -> str:
|
| 277 |
-
"""
|
| 278 |
-
Translate user text to English while preserving technical terms.
|
| 279 |
-
"""
|
| 280 |
-
if not text or not text.strip():
|
| 281 |
-
return text
|
| 282 |
-
if not self._initialized:
|
| 283 |
-
return text
|
| 284 |
-
|
| 285 |
-
try:
|
| 286 |
-
lang_hint = f"Source language: {source_language}. " if source_language else ""
|
| 287 |
-
prompt = (
|
| 288 |
-
"Translate the following support ticket text to natural, concise English. "
|
| 289 |
-
"Preserve technical terms, error codes, product names, and formatting. "
|
| 290 |
-
"Return only translated text with no prefix or explanation. "
|
| 291 |
-
f"{lang_hint}\n\n"
|
| 292 |
-
f"Text:\n{text}"
|
| 293 |
-
)
|
| 294 |
-
response = self.client.models.generate_content(
|
| 295 |
-
model=self.model_name,
|
| 296 |
-
contents=prompt
|
| 297 |
-
)
|
| 298 |
-
translated = (response.text or "").strip()
|
| 299 |
-
return translated or text
|
| 300 |
-
except Exception as e:
|
| 301 |
-
print(f"[GeminiService] Translation error: {e}")
|
| 302 |
-
return text
|
|
|
|
| 2 |
import base64
|
| 3 |
import io
|
| 4 |
import re
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from google import genai
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
from pathlib import Path
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
# Load environment variables from backend/.env
|
| 11 |
env_path = Path(__file__).parent.parent / '.env'
|
| 12 |
load_dotenv(dotenv_path=env_path)
|
|
|
|
| 17 |
self._initialized = False
|
| 18 |
self.model_name = 'gemini-2.5-flash'
|
| 19 |
|
| 20 |
+
if self.api_key:
|
| 21 |
try:
|
| 22 |
self.client = genai.Client(api_key=self.api_key)
|
| 23 |
self._initialized = True
|
|
|
|
| 25 |
except Exception as e:
|
| 26 |
print(f"[GeminiService] Initialization Error: {e}")
|
| 27 |
else:
|
| 28 |
+
print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
def analyze_image(self, image_base64: str) -> dict:
|
| 31 |
"""
|
| 32 |
Perform OCR and image analysis using Gemini logic.
|
| 33 |
"""
|
| 34 |
+
if not self._initialized:
|
| 35 |
return {
|
| 36 |
+
"image_description": "[Gemini API Key Missing] Could not analyze image.",
|
| 37 |
"ocr_text": "",
|
| 38 |
"detected_problem": ""
|
| 39 |
}
|
|
|
|
| 46 |
|
| 47 |
prompt = (
|
| 48 |
"Analyze this screenshot from a user reporting a technical issue. "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
"1. Provide a concise description of what is shown in the image. "
|
| 50 |
"2. Perform OCR and extract any error messages or key text. "
|
| 51 |
"3. Identify the main technical problem depicted. "
|
|
|
|
| 222 |
except Exception as e:
|
| 223 |
print(f"[GeminiService] Bug Analysis Error: {e}")
|
| 224 |
return f"Diagnostic analysis failed: {str(e)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/services/ner_service.py
CHANGED
|
@@ -5,20 +5,12 @@ Labels follow pattern: B-B-ENTITY_TYPE, I-B-ENTITY_TYPE, O
|
|
| 5 |
|
| 6 |
import os
|
| 7 |
import json
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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
|
| 22 |
MAX_LEN = 128
|
| 23 |
|
| 24 |
import re
|
|
@@ -49,10 +41,6 @@ class NERService:
|
|
| 49 |
if self._loaded:
|
| 50 |
return
|
| 51 |
|
| 52 |
-
if not _HAS_TORCH:
|
| 53 |
-
print("[INFO] NER runtime not available; NER model will remain unloaded until dependencies are installed.")
|
| 54 |
-
return
|
| 55 |
-
|
| 56 |
abs_dir = os.path.abspath(SAVE_DIR)
|
| 57 |
|
| 58 |
if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
|
|
|
|
| 5 |
|
| 6 |
import os
|
| 7 |
import json
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
from transformers import DistilBertTokenizerFast, DistilBertForTokenClassification
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "ner")
|
| 13 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 14 |
MAX_LEN = 128
|
| 15 |
|
| 16 |
import re
|
|
|
|
| 41 |
if self._loaded:
|
| 42 |
return
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
abs_dir = os.path.abspath(SAVE_DIR)
|
| 45 |
|
| 46 |
if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
|
backend/services/rag_service.py
CHANGED
|
@@ -1,10 +1,5 @@
|
|
| 1 |
import os
|
| 2 |
-
|
| 3 |
-
from sentence_transformers import SentenceTransformer
|
| 4 |
-
_HAS_SENTENCE = True
|
| 5 |
-
except Exception: # pragma: no cover - optional runtime dependency
|
| 6 |
-
SentenceTransformer = None
|
| 7 |
-
_HAS_SENTENCE = False
|
| 8 |
from supabase import create_client, Client
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
|
|
@@ -32,17 +27,6 @@ class RagService:
|
|
| 32 |
return
|
| 33 |
|
| 34 |
print("[RAG] Loading SentenceTransformer for Knowledge Base...")
|
| 35 |
-
if not _HAS_SENTENCE:
|
| 36 |
-
allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
|
| 37 |
-
self._load_failed = True
|
| 38 |
-
print("[RAG] sentence-transformers not installed")
|
| 39 |
-
if allow_degraded:
|
| 40 |
-
print("[RAG] DEGRADED: Continuing without model (ALLOW_DEGRADED_STARTUP=1)")
|
| 41 |
-
self.model = None
|
| 42 |
-
self._loaded = False
|
| 43 |
-
return
|
| 44 |
-
else:
|
| 45 |
-
raise ImportError("sentence-transformers is required for RagService")
|
| 46 |
try:
|
| 47 |
# Check if a local model path is provided
|
| 48 |
model_path = os.environ.get("SENTENCE_TRANSFORMER_MODEL_PATH")
|
|
|
|
| 1 |
import os
|
| 2 |
+
from sentence_transformers import SentenceTransformer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from supabase import create_client, Client
|
| 4 |
from dotenv import load_dotenv
|
| 5 |
|
|
|
|
| 27 |
return
|
| 28 |
|
| 29 |
print("[RAG] Loading SentenceTransformer for Knowledge Base...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
try:
|
| 31 |
# Check if a local model path is provided
|
| 32 |
model_path = os.environ.get("SENTENCE_TRANSFORMER_MODEL_PATH")
|
backend/services/sla_service.py
CHANGED
|
@@ -14,10 +14,6 @@ import os
|
|
| 14 |
from datetime import datetime, timedelta, timezone
|
| 15 |
from typing import Any, Callable, Optional
|
| 16 |
|
| 17 |
-
from fastapi import BackgroundTasks
|
| 18 |
-
|
| 19 |
-
from backend.sla_checker import dispatch_slack_alert
|
| 20 |
-
|
| 21 |
try:
|
| 22 |
from dotenv import load_dotenv
|
| 23 |
except ImportError:
|
|
@@ -114,12 +110,11 @@ class SlaEscalationService:
|
|
| 114 |
now_fn: Callable[[], datetime] = _utc_now,
|
| 115 |
notification_router: Any = None,
|
| 116 |
):
|
| 117 |
-
|
| 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
|
| 123 |
stats: dict[str, int | str] = {
|
| 124 |
"processed_count": 0,
|
| 125 |
"breached_count": 0,
|
|
@@ -141,7 +136,7 @@ class SlaEscalationService:
|
|
| 141 |
if not self._should_breach(ticket, now):
|
| 142 |
stats["skipped_count"] = int(stats["skipped_count"]) + 1
|
| 143 |
continue
|
| 144 |
-
self._breach_ticket(ticket, now
|
| 145 |
stats["breached_count"] = int(stats["breached_count"]) + 1
|
| 146 |
except Exception as exc:
|
| 147 |
stats["error_count"] = int(stats["error_count"]) + 1
|
|
@@ -174,13 +169,7 @@ class SlaEscalationService:
|
|
| 174 |
return False
|
| 175 |
return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
|
| 176 |
|
| 177 |
-
def _breach_ticket(
|
| 178 |
-
self,
|
| 179 |
-
ticket: dict[str, Any],
|
| 180 |
-
now: datetime,
|
| 181 |
-
*,
|
| 182 |
-
background_tasks: BackgroundTasks | None = None,
|
| 183 |
-
) -> None:
|
| 184 |
ticket_id = str(ticket.get("id"))
|
| 185 |
company_id = ticket.get("company_id")
|
| 186 |
escalation_level = int(ticket.get("escalation_level") or 0) + 1
|
|
@@ -196,7 +185,6 @@ class SlaEscalationService:
|
|
| 196 |
|
| 197 |
self._insert_audit_log(ticket, escalation_level, timestamp)
|
| 198 |
self._emit_system_message(ticket, escalation_level, timestamp)
|
| 199 |
-
self._dispatch_breach_alert(ticket, now, background_tasks=background_tasks)
|
| 200 |
|
| 201 |
logger.warning(
|
| 202 |
"SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
|
|
@@ -206,31 +194,6 @@ class SlaEscalationService:
|
|
| 206 |
escalation_level,
|
| 207 |
)
|
| 208 |
|
| 209 |
-
def _dispatch_breach_alert(
|
| 210 |
-
self,
|
| 211 |
-
ticket: dict[str, Any],
|
| 212 |
-
breach_time: datetime,
|
| 213 |
-
*,
|
| 214 |
-
background_tasks: BackgroundTasks | None = None,
|
| 215 |
-
) -> None:
|
| 216 |
-
ticket_id = str(ticket.get("id") or "")
|
| 217 |
-
subject = str(ticket.get("subject") or "Untitled ticket")
|
| 218 |
-
category = str(ticket.get("priority") or "Uncategorized")
|
| 219 |
-
assignee = str(ticket.get("assigned_team") or "Unassigned")
|
| 220 |
-
|
| 221 |
-
if background_tasks is not None:
|
| 222 |
-
background_tasks.add_task(
|
| 223 |
-
dispatch_slack_alert,
|
| 224 |
-
ticket_id,
|
| 225 |
-
subject,
|
| 226 |
-
category,
|
| 227 |
-
assignee,
|
| 228 |
-
breach_time,
|
| 229 |
-
)
|
| 230 |
-
return
|
| 231 |
-
|
| 232 |
-
dispatch_slack_alert(ticket_id, subject, category, assignee, breach_time)
|
| 233 |
-
|
| 234 |
def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
|
| 235 |
ticket_id = str(ticket.get("id"))
|
| 236 |
self.supabase.table("audit_logs").insert(
|
|
|
|
| 14 |
from datetime import datetime, timedelta, timezone
|
| 15 |
from typing import Any, Callable, Optional
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
try:
|
| 18 |
from dotenv import load_dotenv
|
| 19 |
except ImportError:
|
|
|
|
| 110 |
now_fn: Callable[[], datetime] = _utc_now,
|
| 111 |
notification_router: Any = None,
|
| 112 |
):
|
| 113 |
+
self.supabase = supabase_client
|
|
|
|
| 114 |
self.now_fn = now_fn
|
| 115 |
self.notification_router = notification_router
|
| 116 |
|
| 117 |
+
def run_once(self) -> dict[str, int | str]:
|
| 118 |
stats: dict[str, int | str] = {
|
| 119 |
"processed_count": 0,
|
| 120 |
"breached_count": 0,
|
|
|
|
| 136 |
if not self._should_breach(ticket, now):
|
| 137 |
stats["skipped_count"] = int(stats["skipped_count"]) + 1
|
| 138 |
continue
|
| 139 |
+
self._breach_ticket(ticket, now)
|
| 140 |
stats["breached_count"] = int(stats["breached_count"]) + 1
|
| 141 |
except Exception as exc:
|
| 142 |
stats["error_count"] = int(stats["error_count"]) + 1
|
|
|
|
| 169 |
return False
|
| 170 |
return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
|
| 171 |
|
| 172 |
+
def _breach_ticket(self, ticket: dict[str, Any], now: datetime) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
ticket_id = str(ticket.get("id"))
|
| 174 |
company_id = ticket.get("company_id")
|
| 175 |
escalation_level = int(ticket.get("escalation_level") or 0) + 1
|
|
|
|
| 185 |
|
| 186 |
self._insert_audit_log(ticket, escalation_level, timestamp)
|
| 187 |
self._emit_system_message(ticket, escalation_level, timestamp)
|
|
|
|
| 188 |
|
| 189 |
logger.warning(
|
| 190 |
"SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
|
|
|
|
| 194 |
escalation_level,
|
| 195 |
)
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
|
| 198 |
ticket_id = str(ticket.get("id"))
|
| 199 |
self.supabase.table("audit_logs").insert(
|