Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .gitattributes +0 -23
- .gitignore +0 -7
- Dockerfile +19 -23
- Frontend/eslint.config.js +0 -21
- Frontend/package-lock.json +0 -0
- Frontend/package.json +2 -16
- 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 +2 -8
- 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/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 +2 -59
- Frontend/src/store/ticketStore.js +0 -21
- 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/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/main.py +301 -662
- backend/requirements.txt +1 -7
- backend/services/classifier_service.py +29 -21
- 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 +0 -63
- backend/services/metrics_service.py +31 -0
- backend/services/ner_service.py +4 -16
- backend/services/rag_service.py +1 -17
- backend/services/sla_service.py +3 -39
- deploy/monitoring/grafana_dashboard.json +241 -0
- requirements.txt +1 -7
- supabase/functions/send-user-approval-email/index.ts +56 -131
- supabase/migrations/20260522080000_add_sla_escalation.sql +1 -1
- supabase/migrations/20260531_add_company_settings.sql +0 -60
.gitattributes
CHANGED
|
@@ -2,29 +2,6 @@
|
|
| 2 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
.tmp-ci-venv/Lib/site-packages/dateutil/zoneinfo/dateutil-zoneinfo.tar.gz filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/t64-arm.exe filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/t64.exe filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/w64-arm.exe filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
.tmp-ci-venv/Lib/site-packages/pip/_vendor/distlib/w64.exe filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
.tmp-ci-venv/Lib/site-packages/setuptools/cli-arm64.exe filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
.tmp-ci-venv/Lib/site-packages/setuptools/gui-arm64.exe filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
.tmp-ci-venv/Scripts/dotenv.exe filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
.tmp-ci-venv/Scripts/fastapi.exe filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
.tmp-ci-venv/Scripts/httpx.exe filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
.tmp-ci-venv/Scripts/idna.exe filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
.tmp-ci-venv/Scripts/markdown-it.exe filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
.tmp-ci-venv/Scripts/normalizer.exe filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
.tmp-ci-venv/Scripts/pip.exe filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
.tmp-ci-venv/Scripts/pip3.11.exe filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
.tmp-ci-venv/Scripts/pip3.exe filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
.tmp-ci-venv/Scripts/pygmentize.exe filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
.tmp-ci-venv/Scripts/pyiceberg.exe filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
.tmp-ci-venv/Scripts/python.exe filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
.tmp-ci-venv/Scripts/pythonw.exe filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
.tmp-ci-venv/Scripts/uvicorn.exe filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
.tmp-ci-venv/Scripts/watchfiles.exe filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
.tmp-ci-venv/Scripts/websockets.exe filter=lfs diff=lfs merge=lfs -text
|
| 28 |
Frontend/public/favicon.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 29 |
Frontend/public/favicon.png filter=lfs diff=lfs merge=lfs -text
|
| 30 |
Frontend/public/team/pragati_tiwari.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 2 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
Frontend/public/favicon.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
Frontend/public/favicon.png filter=lfs diff=lfs merge=lfs -text
|
| 7 |
Frontend/public/team/pragati_tiwari.jpg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -34,10 +34,3 @@ docs/logs/
|
|
| 34 |
scratch/
|
| 35 |
.npm-cache/
|
| 36 |
|
| 37 |
-
# GSSoC Score Tracking and Temporary Scripts
|
| 38 |
-
scratch/
|
| 39 |
-
.npm-cache/
|
| 40 |
-
*.tmp
|
| 41 |
-
../MOBILE_SETUP_TEMP.md
|
| 42 |
-
../CI_YML_TEMP.yml
|
| 43 |
-
../ENV_EXAMPLE_TEMP.example
|
|
|
|
| 34 |
scratch/
|
| 35 |
.npm-cache/
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -1,40 +1,36 @@
|
|
| 1 |
-
#
|
|
|
|
| 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
|
@@ -7,11 +7,9 @@
|
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
| 10 |
-
"check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
|
| 11 |
"preview": "vite preview",
|
| 12 |
"prepare": "husky install",
|
| 13 |
-
"format": "prettier --write ."
|
| 14 |
-
"test": "jest"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
"@google/generative-ai": "^0.24.1",
|
|
@@ -44,18 +42,11 @@
|
|
| 44 |
"zustand": "^5.0.11"
|
| 45 |
},
|
| 46 |
"devDependencies": {
|
| 47 |
-
"@babel/core": "^7.24.0",
|
| 48 |
-
"@babel/preset-env": "^7.24.0",
|
| 49 |
-
"@babel/preset-react": "^7.24.0",
|
| 50 |
"@eslint/js": "^9.39.1",
|
| 51 |
-
"@testing-library/dom": "^10.4.1",
|
| 52 |
-
"@testing-library/jest-dom": "^6.6.3",
|
| 53 |
-
"@testing-library/react": "^16.2.0",
|
| 54 |
"@types/react": "^19.2.7",
|
| 55 |
"@types/react-dom": "^19.2.3",
|
| 56 |
"@vitejs/plugin-react": "^5.1.1",
|
| 57 |
"autoprefixer": "^10.4.24",
|
| 58 |
-
"babel-jest": "^29.7.0",
|
| 59 |
"eslint": "^9.39.2",
|
| 60 |
"eslint-config-prettier": "^10.1.8",
|
| 61 |
"eslint-plugin-react": "^7.37.5",
|
|
@@ -63,16 +54,11 @@
|
|
| 63 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 64 |
"globals": "^16.5.0",
|
| 65 |
"husky": "^9.1.7",
|
| 66 |
-
"identity-obj-proxy": "^3.0.0",
|
| 67 |
-
"jest": "^29.7.0",
|
| 68 |
-
"jest-environment-jsdom": "^29.7.0",
|
| 69 |
"lint-staged": "^16.2.7",
|
| 70 |
"postcss": "^8.5.6",
|
| 71 |
"prettier": "^3.8.1",
|
| 72 |
"tailwindcss": "^3.4.19",
|
| 73 |
"vite": "^7.3.1"
|
| 74 |
},
|
| 75 |
-
"lint-staged": {
|
| 76 |
-
"*.{js,jsx}": ["eslint --fix", "prettier --write"]
|
| 77 |
-
}
|
| 78 |
}
|
|
|
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
|
|
|
| 10 |
"preview": "vite preview",
|
| 11 |
"prepare": "husky install",
|
| 12 |
+
"format": "prettier --write ."
|
|
|
|
| 13 |
},
|
| 14 |
"dependencies": {
|
| 15 |
"@google/generative-ai": "^0.24.1",
|
|
|
|
| 42 |
"zustand": "^5.0.11"
|
| 43 |
},
|
| 44 |
"devDependencies": {
|
|
|
|
|
|
|
|
|
|
| 45 |
"@eslint/js": "^9.39.1",
|
|
|
|
|
|
|
|
|
|
| 46 |
"@types/react": "^19.2.7",
|
| 47 |
"@types/react-dom": "^19.2.3",
|
| 48 |
"@vitejs/plugin-react": "^5.1.1",
|
| 49 |
"autoprefixer": "^10.4.24",
|
|
|
|
| 50 |
"eslint": "^9.39.2",
|
| 51 |
"eslint-config-prettier": "^10.1.8",
|
| 52 |
"eslint-plugin-react": "^7.37.5",
|
|
|
|
| 54 |
"eslint-plugin-react-refresh": "^0.4.24",
|
| 55 |
"globals": "^16.5.0",
|
| 56 |
"husky": "^9.1.7",
|
|
|
|
|
|
|
|
|
|
| 57 |
"lint-staged": "^16.2.7",
|
| 58 |
"postcss": "^8.5.6",
|
| 59 |
"prettier": "^3.8.1",
|
| 60 |
"tailwindcss": "^3.4.19",
|
| 61 |
"vite": "^7.3.1"
|
| 62 |
},
|
| 63 |
+
"lint-staged": "{'*.{js,jsx}': ['eslint --fix', 'prettier --write']}"
|
|
|
|
|
|
|
| 64 |
}
|
Frontend/src/App.jsx
CHANGED
|
@@ -5,81 +5,80 @@ import {
|
|
| 5 |
Navigate,
|
| 6 |
useLocation
|
| 7 |
} from "react-router-dom";
|
| 8 |
-
import React, {
|
|
|
|
|
|
|
| 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
|
@@ -4,7 +4,6 @@ import { Activity, AlertTriangle, Clock, ShieldCheck } from 'lucide-react';
|
|
| 4 |
|
| 5 |
import useAuthStore from "../../store/authStore";
|
| 6 |
import { supabase } from "../../lib/supabaseClient";
|
| 7 |
-
import useTicketsRealtime from "../../hooks/useTicketsRealtime";
|
| 8 |
import StatCard from "../components/StatCard";
|
| 9 |
import TicketTable from "../components/TicketTable";
|
| 10 |
import { formatTimelineDate } from "../../utils/dateUtils";
|
|
@@ -108,13 +107,6 @@ const AdminDashboard = () => {
|
|
| 108 |
const [isLoading, setIsLoading] = React.useState(true);
|
| 109 |
const [nowMs, setNowMs] = React.useState(() => Date.now());
|
| 110 |
|
| 111 |
-
useTicketsRealtime({
|
| 112 |
-
company: profile?.company,
|
| 113 |
-
enabled: Boolean(profile),
|
| 114 |
-
onTicketsChange: setTickets,
|
| 115 |
-
channelName: 'admin_dashboard_tickets_realtime',
|
| 116 |
-
});
|
| 117 |
-
|
| 118 |
React.useEffect(() => {
|
| 119 |
if (profile) {
|
| 120 |
const fetchStats = async () => {
|
|
@@ -143,6 +135,8 @@ const AdminDashboard = () => {
|
|
| 143 |
};
|
| 144 |
|
| 145 |
fetchStats();
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
}, [profile]);
|
| 148 |
|
|
|
|
| 4 |
|
| 5 |
import useAuthStore from "../../store/authStore";
|
| 6 |
import { supabase } from "../../lib/supabaseClient";
|
|
|
|
| 7 |
import StatCard from "../components/StatCard";
|
| 8 |
import TicketTable from "../components/TicketTable";
|
| 9 |
import { formatTimelineDate } from "../../utils/dateUtils";
|
|
|
|
| 107 |
const [isLoading, setIsLoading] = React.useState(true);
|
| 108 |
const [nowMs, setNowMs] = React.useState(() => Date.now());
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
React.useEffect(() => {
|
| 111 |
if (profile) {
|
| 112 |
const fetchStats = async () => {
|
|
|
|
| 135 |
};
|
| 136 |
|
| 137 |
fetchStats();
|
| 138 |
+
const interval = setInterval(fetchStats, 30000);
|
| 139 |
+
return () => clearInterval(interval);
|
| 140 |
}
|
| 141 |
}, [profile]);
|
| 142 |
|
Frontend/src/admin/pages/AdminSettings.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React
|
| 2 |
import {
|
| 3 |
Settings,
|
| 4 |
Cpu,
|
|
@@ -8,14 +8,6 @@ import {
|
|
| 8 |
ShieldCheck
|
| 9 |
} from 'lucide-react';
|
| 10 |
import useAdminStore from '../store/adminStore';
|
| 11 |
-
import useAuthStore from '../../store/authStore';
|
| 12 |
-
import { supabase } from '../../lib/supabaseClient';
|
| 13 |
-
import {
|
| 14 |
-
DEFAULT_ADMIN_SETTINGS,
|
| 15 |
-
resolveCompanyId,
|
| 16 |
-
settingsFromSystemSettingsRow,
|
| 17 |
-
settingsToSystemSettingsRow
|
| 18 |
-
} from '../../utils/adminSettingsPersistence';
|
| 19 |
import { Card, CardContent } from "../../components/ui/card";
|
| 20 |
import { Select } from "../../components/ui/select";
|
| 21 |
|
|
@@ -25,97 +17,12 @@ import { Select } from "../../components/ui/select";
|
|
| 25 |
*/
|
| 26 |
const AdminSettings = () => {
|
| 27 |
const { settings, updateSettings } = useAdminStore();
|
| 28 |
-
const { user, profile } = useAuthStore();
|
| 29 |
-
const [isLoadingSettings, setIsLoadingSettings] = useState(false);
|
| 30 |
-
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
| 31 |
-
const [statusMessage, setStatusMessage] = useState('');
|
| 32 |
-
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
| 33 |
-
|
| 34 |
-
const companyId = useMemo(() => resolveCompanyId(profile, user), [profile, user]);
|
| 35 |
-
|
| 36 |
-
useEffect(() => {
|
| 37 |
-
let isMounted = true;
|
| 38 |
-
|
| 39 |
-
const loadCompanySettings = async () => {
|
| 40 |
-
if (!companyId) {
|
| 41 |
-
setStatusMessage('Company profile is required before settings can be synced.');
|
| 42 |
-
return;
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
setIsLoadingSettings(true);
|
| 46 |
-
setStatusMessage('');
|
| 47 |
-
|
| 48 |
-
const { data, error } = await supabase
|
| 49 |
-
.from('system_settings')
|
| 50 |
-
.select('ai_confidence_threshold, duplicate_sensitivity, enable_auto_resolve, auto_close_days, email_notifications, admin_alerts')
|
| 51 |
-
.eq('company_id', companyId)
|
| 52 |
-
.maybeSingle();
|
| 53 |
-
|
| 54 |
-
if (!isMounted) return;
|
| 55 |
-
|
| 56 |
-
if (error) {
|
| 57 |
-
setStatusMessage(`Unable to load saved settings: ${error.message}`);
|
| 58 |
-
} else if (data) {
|
| 59 |
-
updateSettings(settingsFromSystemSettingsRow(data, DEFAULT_ADMIN_SETTINGS));
|
| 60 |
-
setHasUnsavedChanges(false);
|
| 61 |
-
setStatusMessage('Saved company settings loaded.');
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
setIsLoadingSettings(false);
|
| 65 |
-
};
|
| 66 |
-
|
| 67 |
-
loadCompanySettings();
|
| 68 |
-
|
| 69 |
-
return () => {
|
| 70 |
-
isMounted = false;
|
| 71 |
-
};
|
| 72 |
-
}, [companyId, updateSettings]);
|
| 73 |
-
|
| 74 |
-
const saveCompanySettings = useCallback(async (nextSettings, { silent = false } = {}) => {
|
| 75 |
-
if (!companyId) {
|
| 76 |
-
setStatusMessage('Company profile is required before settings can be saved.');
|
| 77 |
-
return;
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
setIsSavingSettings(true);
|
| 81 |
-
if (!silent) {
|
| 82 |
-
setStatusMessage('');
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
const { error } = await supabase
|
| 86 |
-
.from('system_settings')
|
| 87 |
-
.upsert(settingsToSystemSettingsRow(nextSettings, companyId), { onConflict: 'company_id' });
|
| 88 |
-
|
| 89 |
-
if (error) {
|
| 90 |
-
setStatusMessage(`Unable to save settings: ${error.message}`);
|
| 91 |
-
} else {
|
| 92 |
-
setHasUnsavedChanges(false);
|
| 93 |
-
setStatusMessage('Settings saved for this company.');
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
setIsSavingSettings(false);
|
| 97 |
-
}, [companyId]);
|
| 98 |
|
|
|
|
| 99 |
const handleChange = (key, value) => {
|
| 100 |
updateSettings({ [key]: value });
|
| 101 |
-
setHasUnsavedChanges(true);
|
| 102 |
-
setStatusMessage('Saving changes...');
|
| 103 |
};
|
| 104 |
|
| 105 |
-
const handleSaveSettings = useCallback(() => {
|
| 106 |
-
saveCompanySettings(settings);
|
| 107 |
-
}, [saveCompanySettings, settings]);
|
| 108 |
-
|
| 109 |
-
useEffect(() => {
|
| 110 |
-
if (!hasUnsavedChanges || isLoadingSettings || isSavingSettings) return undefined;
|
| 111 |
-
|
| 112 |
-
const saveTimer = window.setTimeout(() => {
|
| 113 |
-
saveCompanySettings(settings, { silent: true });
|
| 114 |
-
}, 800);
|
| 115 |
-
|
| 116 |
-
return () => window.clearTimeout(saveTimer);
|
| 117 |
-
}, [hasUnsavedChanges, isLoadingSettings, isSavingSettings, saveCompanySettings, settings]);
|
| 118 |
-
|
| 119 |
return (
|
| 120 |
<div className="max-w-4xl mx-auto py-6 space-y-10 pb-20 animate-in fade-in duration-700">
|
| 121 |
{/* 1. Header Area */}
|
|
@@ -128,22 +35,6 @@ const AdminSettings = () => {
|
|
| 128 |
<ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
|
| 129 |
</p>
|
| 130 |
</div>
|
| 131 |
-
<div className="flex flex-col items-start md:items-end gap-2">
|
| 132 |
-
<button
|
| 133 |
-
type="button"
|
| 134 |
-
onClick={handleSaveSettings}
|
| 135 |
-
disabled={!companyId || isLoadingSettings || isSavingSettings || !hasUnsavedChanges}
|
| 136 |
-
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-xs font-black uppercase tracking-widest text-white shadow-xl shadow-slate-200 transition-all hover:bg-indigo-600 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
|
| 137 |
-
>
|
| 138 |
-
<Save size={16} />
|
| 139 |
-
{isSavingSettings ? 'Saving...' : 'Save Now'}
|
| 140 |
-
</button>
|
| 141 |
-
{statusMessage && (
|
| 142 |
-
<p className="max-w-xs text-left md:text-right text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
| 143 |
-
{statusMessage}
|
| 144 |
-
</p>
|
| 145 |
-
)}
|
| 146 |
-
</div>
|
| 147 |
</div>
|
| 148 |
|
| 149 |
<div className="space-y-8">
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
import {
|
| 3 |
Settings,
|
| 4 |
Cpu,
|
|
|
|
| 8 |
ShieldCheck
|
| 9 |
} from 'lucide-react';
|
| 10 |
import useAdminStore from '../store/adminStore';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import { Card, CardContent } from "../../components/ui/card";
|
| 12 |
import { Select } from "../../components/ui/select";
|
| 13 |
|
|
|
|
| 17 |
*/
|
| 18 |
const AdminSettings = () => {
|
| 19 |
const { settings, updateSettings } = useAdminStore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
// Handlers
|
| 22 |
const handleChange = (key, value) => {
|
| 23 |
updateSettings({ [key]: value });
|
|
|
|
|
|
|
| 24 |
};
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return (
|
| 27 |
<div className="max-w-4xl mx-auto py-6 space-y-10 pb-20 animate-in fade-in duration-700">
|
| 28 |
{/* 1. Header Area */}
|
|
|
|
| 35 |
<ShieldCheck size={14} className="text-emerald-500" /> Administrator Account
|
| 36 |
</p>
|
| 37 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
|
| 40 |
<div className="space-y-8">
|
Frontend/src/admin/pages/AdminTicketDetail.jsx
CHANGED
|
@@ -16,7 +16,6 @@ import { formatTicketId } from "../../utils/format";
|
|
| 16 |
import SLABadge from "../components/SLABadge";
|
| 17 |
import { formatFullTimestamp } from "../../utils/dateUtils";
|
| 18 |
import TicketTimeline from "../../user/components/TicketTimeline";
|
| 19 |
-
import TicketAuditTimeline from "../components/TicketAuditTimeline";
|
| 20 |
|
| 21 |
const AdminTicketDetail = () => {
|
| 22 |
const { ticket_id } = useParams();
|
|
@@ -35,7 +34,6 @@ const AdminTicketDetail = () => {
|
|
| 35 |
const [imageUrl, setImageUrl] = useState(null);
|
| 36 |
const [isUpdating, setIsUpdating] = useState(null);
|
| 37 |
const [isLive, setIsLive] = useState(false);
|
| 38 |
-
const [showOriginalText, setShowOriginalText] = useState(false);
|
| 39 |
|
| 40 |
const [correctionForm, setCorrectionForm] = useState({
|
| 41 |
category: '',
|
|
@@ -199,11 +197,6 @@ const AdminTicketDetail = () => {
|
|
| 199 |
const displayPriority = ticket.priority || 'Medium';
|
| 200 |
const displaySummary = ticket.summary || ticket.subject || 'No Summary';
|
| 201 |
const displayText = ticket.description || ticket.text || displaySummary;
|
| 202 |
-
const translationMeta = ticket.metadata?.translation;
|
| 203 |
-
const originalTextMeta = ticket.metadata?.original_text;
|
| 204 |
-
const isTranslated = Boolean(translationMeta?.translated && originalTextMeta?.description);
|
| 205 |
-
const sourceLanguageName = translationMeta?.source_language_name || translationMeta?.source_language || 'Unknown';
|
| 206 |
-
const renderedText = showOriginalText && isTranslated ? originalTextMeta.description : displayText;
|
| 207 |
|
| 208 |
return (
|
| 209 |
<div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
|
|
@@ -319,22 +312,8 @@ const AdminTicketDetail = () => {
|
|
| 319 |
<span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
|
| 320 |
</div>
|
| 321 |
<div style={{ padding: '28px' }}>
|
| 322 |
-
{isTranslated && (
|
| 323 |
-
<div style={{ marginBottom: '16px', border: '1px solid #bae6fd', background: '#f0f9ff', borderRadius: '12px', padding: '10px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
| 324 |
-
<span style={{ fontSize: '12px', fontWeight: 600, color: '#0c4a6e' }}>
|
| 325 |
-
Translated from {sourceLanguageName}
|
| 326 |
-
</span>
|
| 327 |
-
<button
|
| 328 |
-
type="button"
|
| 329 |
-
onClick={() => setShowOriginalText(prev => !prev)}
|
| 330 |
-
style={{ fontSize: '11px', fontWeight: 700, color: '#0369a1', background: 'transparent', border: 'none', cursor: 'pointer' }}
|
| 331 |
-
>
|
| 332 |
-
{showOriginalText ? 'View English' : 'View Original'}
|
| 333 |
-
</button>
|
| 334 |
-
</div>
|
| 335 |
-
)}
|
| 336 |
<div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
|
| 337 |
-
"{
|
| 338 |
</div>
|
| 339 |
|
| 340 |
{imageUrl && (
|
|
@@ -363,8 +342,6 @@ const AdminTicketDetail = () => {
|
|
| 363 |
</div>
|
| 364 |
<TicketTimeline ticket={ticket} />
|
| 365 |
</div>
|
| 366 |
-
|
| 367 |
-
<TicketAuditTimeline ticketId={ticket.id} companyId={ticket.company_id} />
|
| 368 |
</div>
|
| 369 |
|
| 370 |
{/* AI Column */}
|
|
|
|
| 16 |
import SLABadge from "../components/SLABadge";
|
| 17 |
import { formatFullTimestamp } from "../../utils/dateUtils";
|
| 18 |
import TicketTimeline from "../../user/components/TicketTimeline";
|
|
|
|
| 19 |
|
| 20 |
const AdminTicketDetail = () => {
|
| 21 |
const { ticket_id } = useParams();
|
|
|
|
| 34 |
const [imageUrl, setImageUrl] = useState(null);
|
| 35 |
const [isUpdating, setIsUpdating] = useState(null);
|
| 36 |
const [isLive, setIsLive] = useState(false);
|
|
|
|
| 37 |
|
| 38 |
const [correctionForm, setCorrectionForm] = useState({
|
| 39 |
category: '',
|
|
|
|
| 197 |
const displayPriority = ticket.priority || 'Medium';
|
| 198 |
const displaySummary = ticket.summary || ticket.subject || 'No Summary';
|
| 199 |
const displayText = ticket.description || ticket.text || displaySummary;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
return (
|
| 202 |
<div style={{ background: '#f8faf9', minHeight: '100vh', paddingBottom: '80px' }} className="-m-6 p-6 md:-m-10 md:p-10 space-y-6 animate-in fade-in duration-700">
|
|
|
|
| 312 |
<span style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase' }}>{formatFullTimestamp(ticket.created_at)}</span>
|
| 313 |
</div>
|
| 314 |
<div style={{ padding: '28px' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
<div style={{ background: 'linear-gradient(135deg, #0f1f12, #1a3320)', color: '#ffffff', borderRadius: '16px', padding: '24px 28px', fontSize: '15px', fontStyle: 'italic', lineHeight: 1.7 }}>
|
| 316 |
+
"{displayText}"
|
| 317 |
</div>
|
| 318 |
|
| 319 |
{imageUrl && (
|
|
|
|
| 342 |
</div>
|
| 343 |
<TicketTimeline ticket={ticket} />
|
| 344 |
</div>
|
|
|
|
|
|
|
| 345 |
</div>
|
| 346 |
|
| 347 |
{/* AI Column */}
|
Frontend/src/admin/pages/AdminTickets.jsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
-
import React, {
|
| 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>
|
|
@@ -340,11 +347,6 @@ const AdminTickets = () => {
|
|
| 340 |
{ticket.category}
|
| 341 |
<span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
|
| 342 |
</span>
|
| 343 |
-
{ticket?.metadata?.translation?.translated && (
|
| 344 |
-
<span className="text-[10px] text-sky-700 mt-1">
|
| 345 |
-
Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
|
| 346 |
-
</span>
|
| 347 |
-
)}
|
| 348 |
</div>
|
| 349 |
</td>
|
| 350 |
|
|
@@ -454,8 +456,7 @@ const AdminTickets = () => {
|
|
| 454 |
</div>
|
| 455 |
</td>
|
| 456 |
</tr>
|
| 457 |
-
|
| 458 |
-
})}
|
| 459 |
</tbody>
|
| 460 |
</table>
|
| 461 |
</div>
|
|
|
|
| 1 |
+
import React, { useState, useMemo, useEffect } from 'react';
|
| 2 |
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
import useAuthStore from "../../store/authStore";
|
| 4 |
import useToastStore from "../../store/toastStore";
|
| 5 |
import { supabase } from "../../lib/supabaseClient";
|
|
|
|
| 6 |
import {
|
| 7 |
Search,
|
| 8 |
Filter,
|
|
|
|
| 29 |
const AdminTickets = () => {
|
| 30 |
const navigate = useNavigate();
|
| 31 |
const location = useLocation();
|
| 32 |
+
const { user } = useAuthStore();
|
| 33 |
const { showToast } = useToastStore();
|
| 34 |
|
| 35 |
// Data State
|
|
|
|
| 46 |
const [teamFilter, setTeamFilter] = useState('All');
|
| 47 |
const [agents, setAgents] = useState([]); // All staff/admins in the company
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
const fetchInitialData = async () => {
|
| 50 |
setLoading(true);
|
| 51 |
try {
|
|
|
|
| 113 |
|
| 114 |
useEffect(() => {
|
| 115 |
fetchInitialData();
|
| 116 |
+
|
| 117 |
+
// 4. Real-time subscription to ticket changes
|
| 118 |
+
const { profile } = useAuthStore.getState();
|
| 119 |
+
const channel = supabase
|
| 120 |
+
.channel('admin_tickets_realtime')
|
| 121 |
+
.on(
|
| 122 |
+
'postgres_changes',
|
| 123 |
+
{
|
| 124 |
+
event: '*',
|
| 125 |
+
schema: 'public',
|
| 126 |
+
table: 'tickets',
|
| 127 |
+
filter: profile?.company ? `company=eq.${profile.company}` : undefined
|
| 128 |
+
},
|
| 129 |
+
(payload) => {
|
| 130 |
+
console.log("Admin tickets sync event:", payload.eventType, payload.new);
|
| 131 |
+
if (payload.eventType === 'INSERT') {
|
| 132 |
+
setTickets(prev => [payload.new, ...prev]);
|
| 133 |
+
showToast(`New Incident Reported: #${payload.new.id}`, "success");
|
| 134 |
+
// Play a subtle sound or visual cue if needed
|
| 135 |
+
} else if (payload.eventType === 'UPDATE') {
|
| 136 |
+
setTickets(prev => prev.map(t => t.id === payload.new.id ? { ...t, ...payload.new } : t));
|
| 137 |
+
} else if (payload.eventType === 'DELETE') {
|
| 138 |
+
setTickets(prev => prev.filter(t => t.id === payload.old.id));
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
.subscribe();
|
| 143 |
+
|
| 144 |
+
return () => {
|
| 145 |
+
supabase.removeChannel(channel);
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
}, [statusFilter, categoryFilter, priorityFilter, teamFilter]);
|
| 149 |
|
| 150 |
// Seed search from URL
|
|
|
|
| 305 |
</tr>
|
| 306 |
</thead>
|
| 307 |
<tbody className="divide-y divide-slate-50">
|
| 308 |
+
{filteredTickets.map((ticket) => (
|
| 309 |
+
<tr key={ticket.id} className={`hover:bg-slate-50/50 transition-colors group ${isUpdating === ticket.id ? 'opacity-50 pointer-events-none' : ''}`}>
|
|
|
|
|
|
|
|
|
|
| 310 |
{/* Ticket ID */}
|
| 311 |
<td className="px-6 py-6">
|
| 312 |
<span className="font-mono text-xs font-black text-emerald-600">#{formatTicketId(ticket.id)}</span>
|
|
|
|
| 347 |
{ticket.category}
|
| 348 |
<span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
|
| 349 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
</div>
|
| 351 |
</td>
|
| 352 |
|
|
|
|
| 456 |
</div>
|
| 457 |
</td>
|
| 458 |
</tr>
|
| 459 |
+
))}
|
|
|
|
| 460 |
</tbody>
|
| 461 |
</table>
|
| 462 |
</div>
|
Frontend/src/components/shared/BugReportWidget.jsx
CHANGED
|
@@ -23,6 +23,7 @@ function useDiagnostics() {
|
|
| 23 |
const browserInfo = navigator.userAgent;
|
| 24 |
const screenInfo = `${window.innerWidth}x${window.innerHeight}`;
|
| 25 |
|
|
|
|
| 26 |
setDiagnostics(prev => ({
|
| 27 |
...prev,
|
| 28 |
url: window.location.href,
|
|
@@ -30,6 +31,16 @@ function useDiagnostics() {
|
|
| 30 |
screen: screenInfo
|
| 31 |
}));
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
// Global Error Listener
|
| 34 |
const handleError = (e) => {
|
| 35 |
setDiagnostics(prev => ({
|
|
@@ -40,6 +51,7 @@ function useDiagnostics() {
|
|
| 40 |
window.addEventListener('error', handleError);
|
| 41 |
|
| 42 |
return () => {
|
|
|
|
| 43 |
window.removeEventListener('error', handleError);
|
| 44 |
};
|
| 45 |
}, []);
|
|
|
|
| 23 |
const browserInfo = navigator.userAgent;
|
| 24 |
const screenInfo = `${window.innerWidth}x${window.innerHeight}`;
|
| 25 |
|
| 26 |
+
|
| 27 |
setDiagnostics(prev => ({
|
| 28 |
...prev,
|
| 29 |
url: window.location.href,
|
|
|
|
| 31 |
screen: screenInfo
|
| 32 |
}));
|
| 33 |
|
| 34 |
+
// Intercept console.error
|
| 35 |
+
const originalConsoleError = console.error;
|
| 36 |
+
console.error = function (...args) {
|
| 37 |
+
setDiagnostics(prev => ({
|
| 38 |
+
...prev,
|
| 39 |
+
consoleErrors: [...prev.consoleErrors, args.join(' ')].slice(-10) // keep last 10
|
| 40 |
+
}));
|
| 41 |
+
originalConsoleError.apply(console, args);
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
// Global Error Listener
|
| 45 |
const handleError = (e) => {
|
| 46 |
setDiagnostics(prev => ({
|
|
|
|
| 51 |
window.addEventListener('error', handleError);
|
| 52 |
|
| 53 |
return () => {
|
| 54 |
+
console.error = originalConsoleError;
|
| 55 |
window.removeEventListener('error', handleError);
|
| 56 |
};
|
| 57 |
}, []);
|
Frontend/src/config.js
CHANGED
|
@@ -13,6 +13,5 @@ const getBackendUrl = () => {
|
|
| 13 |
export const API_CONFIG = {
|
| 14 |
BACKEND_URL: getBackendUrl(),
|
| 15 |
FRONTEND_URL: window.location.origin,
|
| 16 |
-
IS_PROD: import.meta.env.PROD
|
| 17 |
-
USE_MOCK: import.meta.env.VITE_USE_MOCK !== 'false' // default true
|
| 18 |
};
|
|
|
|
| 13 |
export const API_CONFIG = {
|
| 14 |
BACKEND_URL: getBackendUrl(),
|
| 15 |
FRONTEND_URL: window.location.origin,
|
| 16 |
+
IS_PROD: import.meta.env.PROD
|
|
|
|
| 17 |
};
|
Frontend/src/pages/AdminSignup.jsx
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
| 10 |
} from "lucide-react";
|
| 11 |
import useAuthStore from "../store/authStore";
|
| 12 |
import { Select } from "../components/ui/select";
|
| 13 |
-
import { getPasswordValidation, getPasswordValidationMessage } from "../utils/passwordValidation";
|
| 14 |
|
| 15 |
/**
|
| 16 |
* AdminSignup — Premium Multi-step Company Registration
|
|
@@ -45,14 +44,6 @@ function AdminSignup() {
|
|
| 45 |
|
| 46 |
const navigate = useNavigate();
|
| 47 |
const { signup, loading, user, profile } = useAuthStore();
|
| 48 |
-
const passwordRules = {
|
| 49 |
-
minLength: 8,
|
| 50 |
-
requireUppercase: true,
|
| 51 |
-
requireNumber: true,
|
| 52 |
-
requireSpecial: true,
|
| 53 |
-
};
|
| 54 |
-
const passwordChecks = getPasswordValidation(formData.password, passwordRules);
|
| 55 |
-
const passwordWarning = getPasswordValidationMessage(passwordChecks, passwordRules);
|
| 56 |
|
| 57 |
// Redirect if already logged in and verified
|
| 58 |
useEffect(() => {
|
|
@@ -89,8 +80,8 @@ function AdminSignup() {
|
|
| 89 |
setError("Please fill in all required personal information.");
|
| 90 |
return;
|
| 91 |
}
|
| 92 |
-
if (
|
| 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,40 +1,8 @@
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { persist } from 'zustand/middleware';
|
| 3 |
import { supabase } from '../lib/supabaseClient';
|
| 4 |
-
import { API_CONFIG } from '../config';
|
| 5 |
import useTicketStore from './ticketStore';
|
| 6 |
|
| 7 |
-
const BACKEND_URL = API_CONFIG.BACKEND_URL;
|
| 8 |
-
|
| 9 |
-
const verifyServerCookieSession = async () => {
|
| 10 |
-
try {
|
| 11 |
-
const res = await fetch(`${BACKEND_URL}/auth/me`, {
|
| 12 |
-
method: 'GET',
|
| 13 |
-
credentials: 'include',
|
| 14 |
-
headers: { Accept: 'application/json' },
|
| 15 |
-
});
|
| 16 |
-
if (!res.ok) return null;
|
| 17 |
-
const body = await res.json();
|
| 18 |
-
return body?.user || null;
|
| 19 |
-
} catch (e) {
|
| 20 |
-
console.warn('Server cookie session check failed:', e?.message || e);
|
| 21 |
-
return null;
|
| 22 |
-
}
|
| 23 |
-
};
|
| 24 |
-
|
| 25 |
-
const mirrorBackendAuth = async (path, payload) => {
|
| 26 |
-
try {
|
| 27 |
-
await fetch(`${BACKEND_URL}${path}`, {
|
| 28 |
-
method: 'POST',
|
| 29 |
-
credentials: 'include',
|
| 30 |
-
headers: { 'Content-Type': 'application/json' },
|
| 31 |
-
body: JSON.stringify(payload),
|
| 32 |
-
});
|
| 33 |
-
} catch (e) {
|
| 34 |
-
console.warn(`Backend auth ${path} failed:`, e?.message || e);
|
| 35 |
-
}
|
| 36 |
-
};
|
| 37 |
-
|
| 38 |
let currentUserPromise = null;
|
| 39 |
|
| 40 |
const useAuthStore = create(
|
|
@@ -121,13 +89,6 @@ const useAuthStore = create(
|
|
| 121 |
currentUserPromise = (async () => {
|
| 122 |
try {
|
| 123 |
set({ isCheckingSession: true });
|
| 124 |
-
const cookieUser = await verifyServerCookieSession();
|
| 125 |
-
if (cookieUser) {
|
| 126 |
-
set({ user: cookieUser });
|
| 127 |
-
get().getProfile(cookieUser);
|
| 128 |
-
return cookieUser;
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
const { data: { user }, error } = await supabase.auth.getUser();
|
| 132 |
if (error) throw error;
|
| 133 |
|
|
@@ -156,8 +117,6 @@ const useAuthStore = create(
|
|
| 156 |
set({ loading: true });
|
| 157 |
console.log("Attempting login for:", email);
|
| 158 |
try {
|
| 159 |
-
await mirrorBackendAuth('/auth/login', { email, password });
|
| 160 |
-
|
| 161 |
const { data, error } = await supabase.auth.signInWithPassword({
|
| 162 |
email,
|
| 163 |
password,
|
|
@@ -246,14 +205,6 @@ const useAuthStore = create(
|
|
| 246 |
console.log("Starting signup for:", email);
|
| 247 |
|
| 248 |
try {
|
| 249 |
-
await mirrorBackendAuth('/auth/signup', {
|
| 250 |
-
email,
|
| 251 |
-
password,
|
| 252 |
-
full_name: fullName,
|
| 253 |
-
role,
|
| 254 |
-
company,
|
| 255 |
-
});
|
| 256 |
-
|
| 257 |
// 1. Auth Signup with Metadata
|
| 258 |
console.log("Step 1: Auth.signUp...");
|
| 259 |
const { data, error } = await supabase.auth.signUp({
|
|
@@ -294,15 +245,6 @@ const useAuthStore = create(
|
|
| 294 |
logout: async () => {
|
| 295 |
set({ loading: true });
|
| 296 |
try {
|
| 297 |
-
try {
|
| 298 |
-
await fetch(`${BACKEND_URL}/auth/logout`, {
|
| 299 |
-
method: 'POST',
|
| 300 |
-
credentials: 'include',
|
| 301 |
-
});
|
| 302 |
-
} catch (e) {
|
| 303 |
-
console.warn('Backend cookie logout failed:', e?.message || e);
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
const { error } = await supabase.auth.signOut();
|
| 307 |
if (error) throw error;
|
| 308 |
set({ user: null, profile: null });
|
|
@@ -363,7 +305,8 @@ const useAuthStore = create(
|
|
| 363 |
{
|
| 364 |
name: 'auth-storage',
|
| 365 |
partialize: (state) => ({
|
| 366 |
-
//
|
|
|
|
| 367 |
profile: state.profile
|
| 368 |
}),
|
| 369 |
}
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { persist } from 'zustand/middleware';
|
| 3 |
import { supabase } from '../lib/supabaseClient';
|
|
|
|
| 4 |
import useTicketStore from './ticketStore';
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
let currentUserPromise = null;
|
| 7 |
|
| 8 |
const useAuthStore = create(
|
|
|
|
| 89 |
currentUserPromise = (async () => {
|
| 90 |
try {
|
| 91 |
set({ isCheckingSession: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
const { data: { user }, error } = await supabase.auth.getUser();
|
| 93 |
if (error) throw error;
|
| 94 |
|
|
|
|
| 117 |
set({ loading: true });
|
| 118 |
console.log("Attempting login for:", email);
|
| 119 |
try {
|
|
|
|
|
|
|
| 120 |
const { data, error } = await supabase.auth.signInWithPassword({
|
| 121 |
email,
|
| 122 |
password,
|
|
|
|
| 205 |
console.log("Starting signup for:", email);
|
| 206 |
|
| 207 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
// 1. Auth Signup with Metadata
|
| 209 |
console.log("Step 1: Auth.signUp...");
|
| 210 |
const { data, error } = await supabase.auth.signUp({
|
|
|
|
| 245 |
logout: async () => {
|
| 246 |
set({ loading: true });
|
| 247 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
const { error } = await supabase.auth.signOut();
|
| 249 |
if (error) throw error;
|
| 250 |
set({ user: null, profile: null });
|
|
|
|
| 305 |
{
|
| 306 |
name: 'auth-storage',
|
| 307 |
partialize: (state) => ({
|
| 308 |
+
// We keep profile persisted for quick UI transitions,
|
| 309 |
+
// but session is handled by Supabase cookie/localStorage
|
| 310 |
profile: state.profile
|
| 311 |
}),
|
| 312 |
}
|
Frontend/src/store/ticketStore.js
CHANGED
|
@@ -30,27 +30,6 @@ const useTicketStore = create(
|
|
| 30 |
tickets: [...state.tickets, ticket]
|
| 31 |
};
|
| 32 |
}),
|
| 33 |
-
upsertTicket: (ticket) => set((state) => {
|
| 34 |
-
const ticketId = ticket?.id ?? ticket?.ticket_id;
|
| 35 |
-
if (!ticketId) return state;
|
| 36 |
-
|
| 37 |
-
const exists = state.tickets.some(t => (t.id ?? t.ticket_id) === ticketId);
|
| 38 |
-
const tickets = exists
|
| 39 |
-
? state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...ticket } : t)
|
| 40 |
-
: [ticket, ...state.tickets];
|
| 41 |
-
const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
|
| 42 |
-
|
| 43 |
-
return {
|
| 44 |
-
tickets,
|
| 45 |
-
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...ticket } : state.activeTicket
|
| 46 |
-
};
|
| 47 |
-
}),
|
| 48 |
-
removeTicket: (ticketId) => set((state) => ({
|
| 49 |
-
tickets: state.tickets.filter(t => (t.id ?? t.ticket_id) !== ticketId),
|
| 50 |
-
activeTicket: (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId
|
| 51 |
-
? null
|
| 52 |
-
: state.activeTicket
|
| 53 |
-
})),
|
| 54 |
updateTicket: (ticketId, updates) => set((state) => {
|
| 55 |
// eslint-disable-next-line no-unused-vars
|
| 56 |
const existingTicket = state.tickets.find(t => t.ticket_id === ticketId);
|
|
|
|
| 30 |
tickets: [...state.tickets, ticket]
|
| 31 |
};
|
| 32 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
updateTicket: (ticketId, updates) => set((state) => {
|
| 34 |
// eslint-disable-next-line no-unused-vars
|
| 35 |
const existingTicket = state.tickets.find(t => t.ticket_id === ticketId);
|
Frontend/src/user/components/RecentTickets.jsx
CHANGED
|
@@ -150,11 +150,6 @@ const RecentTickets = () => {
|
|
| 150 |
<p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
|
| 151 |
{ticket.summary || ticket.subject || ticket.description || "No description provided"}
|
| 152 |
</p>
|
| 153 |
-
{ticket?.metadata?.translation?.translated && (
|
| 154 |
-
<p style={{ fontSize: '11px', color: '#0369a1', margin: '4px 0 0' }}>
|
| 155 |
-
Translated from {ticket.metadata.translation.source_language_name || ticket.metadata.translation.source_language || 'Unknown'}
|
| 156 |
-
</p>
|
| 157 |
-
)}
|
| 158 |
</td>
|
| 159 |
<td style={{ padding: '16px 28px' }}>
|
| 160 |
{getStatusBadge(ticket.status)}
|
|
|
|
| 150 |
<p style={{ fontSize: '14px', fontWeight: 500, color: '#111827', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '320px' }}>
|
| 151 |
{ticket.summary || ticket.subject || ticket.description || "No description provided"}
|
| 152 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
</td>
|
| 154 |
<td style={{ padding: '16px 28px' }}>
|
| 155 |
{getStatusBadge(ticket.status)}
|
Frontend/src/user/pages/AutoResolveChat.jsx
CHANGED
|
@@ -19,7 +19,6 @@ const AutoResolveChat = () => {
|
|
| 19 |
const { aiTicket } = useTicketStore();
|
| 20 |
const navigate = useNavigate();
|
| 21 |
const [messages, setMessages] = useState([]);
|
| 22 |
-
const [steps, setSteps] = useState([]);
|
| 23 |
const [isThinking, setIsThinking] = useState(false);
|
| 24 |
const [isFinal, setIsFinal] = useState(false);
|
| 25 |
const [inputText, setInputText] = useState('');
|
|
@@ -78,7 +77,7 @@ const AutoResolveChat = () => {
|
|
| 78 |
}
|
| 79 |
|
| 80 |
if (newSteps.length >= 2) {
|
| 81 |
-
|
| 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/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/main.py
CHANGED
|
@@ -8,7 +8,6 @@ import os
|
|
| 8 |
import sys
|
| 9 |
import uuid
|
| 10 |
import json
|
| 11 |
-
import re
|
| 12 |
import datetime
|
| 13 |
import traceback
|
| 14 |
import warnings
|
|
@@ -25,7 +24,8 @@ from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
| 25 |
from slowapi.util import get_remote_address
|
| 26 |
from slowapi.errors import RateLimitExceeded
|
| 27 |
from fastapi.middleware.cors import CORSMiddleware
|
| 28 |
-
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
|
|
| 29 |
from fastapi.encoders import jsonable_encoder
|
| 30 |
import asyncio
|
| 31 |
from pathlib import Path
|
|
@@ -36,13 +36,6 @@ from dotenv import load_dotenv
|
|
| 36 |
env_path = Path(__file__).parent / '.env'
|
| 37 |
load_dotenv(dotenv_path=env_path)
|
| 38 |
|
| 39 |
-
# CI smoke tests allow degraded startup so the app can import without heavy ML assets.
|
| 40 |
-
ALLOW_DEGRADED_STARTUP = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def _startup_fatal(message: str) -> None:
|
| 44 |
-
print(f"[Startup-FATAL] {message}")
|
| 45 |
-
|
| 46 |
# Initialize Supabase Client (Service Role for backend bypass)
|
| 47 |
try:
|
| 48 |
from supabase import create_client, Client
|
|
@@ -64,14 +57,16 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")
|
|
| 64 |
from backend.services.classifier_service import ClassifierService
|
| 65 |
from backend.services.classifier_v2 import classifier_v2
|
| 66 |
from backend.services.classifier_v3 import classifier_v3 # V3 Power Model
|
| 67 |
-
from backend.services.audit_service import AuditLogService, AuditLogAccessError
|
| 68 |
-
from backend.services.onnx_service import onnx_classifier
|
| 69 |
from backend.services.ner_service import NERService
|
| 70 |
from backend.services.duplicate_service import DuplicateService
|
| 71 |
from backend.services.rag_service import RagService
|
| 72 |
-
from backend.services.
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
# ---------------------------------------------------------------------------
|
|
@@ -120,62 +115,13 @@ def detect_semantic_duplicate(text: str, *, company_id: str | None, threshold: f
|
|
| 120 |
duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
|
| 121 |
duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
|
| 122 |
return duplicate_result
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def classify_ticket_text(text: str) -> dict:
|
| 126 |
-
"""Run the local classifier cascade with ONNX as the offline fallback path."""
|
| 127 |
-
cached = redis_cache.get_classification(text)
|
| 128 |
-
if cached:
|
| 129 |
-
return cached
|
| 130 |
-
|
| 131 |
-
result = _classify_ticket_text_uncached(text)
|
| 132 |
-
redis_cache.set_classification(text, result)
|
| 133 |
-
return result
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def _classify_ticket_text_uncached(text: str) -> dict:
|
| 137 |
-
try:
|
| 138 |
-
classification_v3_res = classifier_v3.predict(text)
|
| 139 |
-
if "error" not in classification_v3_res:
|
| 140 |
-
cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
|
| 141 |
-
sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
|
| 142 |
-
pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
|
| 143 |
-
conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
|
| 144 |
-
|
| 145 |
-
from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
|
| 146 |
-
return {
|
| 147 |
-
"category": cat,
|
| 148 |
-
"subcategory": sub,
|
| 149 |
-
"priority": pri,
|
| 150 |
-
"auto_resolve": sub in AUTO_RESOLVE_SUBS,
|
| 151 |
-
"assigned_team": TEAM_MAP.get(cat, "General Support"),
|
| 152 |
-
"confidence": float(conf),
|
| 153 |
-
}
|
| 154 |
-
except Exception:
|
| 155 |
-
traceback.print_exc()
|
| 156 |
-
|
| 157 |
-
try:
|
| 158 |
-
onnx_result = onnx_classifier.predict(text)
|
| 159 |
-
if onnx_result:
|
| 160 |
-
return onnx_result
|
| 161 |
-
except Exception as error:
|
| 162 |
-
print(f"[ONNX] Fallback classification skipped: {error}")
|
| 163 |
-
|
| 164 |
-
try:
|
| 165 |
-
return classifier_service.predict(text)
|
| 166 |
-
except Exception:
|
| 167 |
-
traceback.print_exc()
|
| 168 |
-
return {
|
| 169 |
-
"category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
|
| 170 |
-
"auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
|
| 171 |
-
}
|
| 172 |
-
|
| 173 |
class TicketRequest(BaseModel):
|
| 174 |
text: str
|
| 175 |
image_base64: str = ""
|
| 176 |
image_text: str = "" # Keep for backward compatibility
|
| 177 |
user_id: str | None = None
|
| 178 |
company: str | None = None
|
|
|
|
| 179 |
image_url: str | None = None
|
| 180 |
confidence_threshold: float = 0.20
|
| 181 |
duplicate_sensitivity: float = 0.85
|
|
@@ -195,6 +141,10 @@ class TicketSaveRequest(BaseModel):
|
|
| 195 |
image_url: str | None = None
|
| 196 |
company: str | None = None
|
| 197 |
company_id: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
sla_breach_at: str
|
| 199 |
sla_status: str | None = None
|
| 200 |
escalation_level: int = 0
|
|
@@ -210,6 +160,8 @@ class TicketSaveRequest(BaseModel):
|
|
| 210 |
class DuplicateInfo(BaseModel):
|
| 211 |
is_duplicate: bool
|
| 212 |
duplicate_ticket_id: str | None = None
|
|
|
|
|
|
|
| 213 |
similarity: float = 0.0
|
| 214 |
|
| 215 |
|
|
@@ -231,6 +183,8 @@ class TicketResponse(BaseModel):
|
|
| 231 |
entities: list[EntityInfo]
|
| 232 |
duplicate_ticket: DuplicateInfo
|
| 233 |
confidence: float
|
|
|
|
|
|
|
| 234 |
needs_review: bool = False
|
| 235 |
reasoning: str = ""
|
| 236 |
decision_factors: list[str] = []
|
|
@@ -240,10 +194,6 @@ class TicketResponse(BaseModel):
|
|
| 240 |
timeline: dict = {} # Map of step_name: timestamp
|
| 241 |
env_metadata: dict = {} # IP, Hostname, Browser/OS
|
| 242 |
sla_breach_at: str | None = None
|
| 243 |
-
original_text: str | None = None
|
| 244 |
-
source_language: str = "en"
|
| 245 |
-
source_language_name: str = "English"
|
| 246 |
-
was_translated: bool = False
|
| 247 |
version: str = "2.1.0-Neural-Diagnostic"
|
| 248 |
|
| 249 |
|
|
@@ -271,24 +221,6 @@ class TicketRecord(BaseModel):
|
|
| 271 |
timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
|
| 272 |
|
| 273 |
|
| 274 |
-
class AuditLogProfile(BaseModel):
|
| 275 |
-
full_name: str | None = None
|
| 276 |
-
email: str | None = None
|
| 277 |
-
profile_picture: str | None = None
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
class AuditLogRecord(BaseModel):
|
| 281 |
-
id: str
|
| 282 |
-
ticket_id: str
|
| 283 |
-
company_id: str
|
| 284 |
-
performed_by: str | None = None
|
| 285 |
-
action: str
|
| 286 |
-
old_value: dict | list | str | None = None
|
| 287 |
-
new_value: dict | list | str | None = None
|
| 288 |
-
created_at: str
|
| 289 |
-
performed_by_profile: AuditLogProfile | None = None
|
| 290 |
-
|
| 291 |
-
|
| 292 |
# --- In-Memory Database (to be replaced with SQL later) ---
|
| 293 |
TICKETS_DB: list[TicketRecord] = []
|
| 294 |
|
|
@@ -311,8 +243,6 @@ classifier_service = ClassifierService()
|
|
| 311 |
ner_service = NERService()
|
| 312 |
duplicate_service = DuplicateService()
|
| 313 |
rag_service = RagService()
|
| 314 |
-
sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
|
| 315 |
-
semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
|
| 316 |
|
| 317 |
try:
|
| 318 |
from backend.services.gemini_service import GeminiService
|
|
@@ -326,82 +256,6 @@ try:
|
|
| 326 |
except ImportError:
|
| 327 |
ocr_service = None
|
| 328 |
|
| 329 |
-
LANGUAGE_NAMES = {
|
| 330 |
-
"en": "English",
|
| 331 |
-
"es": "Spanish",
|
| 332 |
-
"de": "German",
|
| 333 |
-
"hi": "Hindi",
|
| 334 |
-
"fr": "French",
|
| 335 |
-
"it": "Italian",
|
| 336 |
-
"pt": "Portuguese",
|
| 337 |
-
"ja": "Japanese",
|
| 338 |
-
"ko": "Korean",
|
| 339 |
-
"zh": "Chinese",
|
| 340 |
-
"ar": "Arabic",
|
| 341 |
-
"ru": "Russian",
|
| 342 |
-
}
|
| 343 |
-
|
| 344 |
-
def _heuristic_language_detection(text: str) -> dict:
|
| 345 |
-
sample = (text or "").strip()
|
| 346 |
-
if not sample:
|
| 347 |
-
return {"code": "en", "name": "English"}
|
| 348 |
-
ascii_chars = sum(1 for c in sample if ord(c) < 128)
|
| 349 |
-
ratio = ascii_chars / max(len(sample), 1)
|
| 350 |
-
if ratio > 0.97:
|
| 351 |
-
return {"code": "en", "name": "English"}
|
| 352 |
-
return {"code": "unknown", "name": "Unknown"}
|
| 353 |
-
|
| 354 |
-
def detect_and_translate_ticket_text(text: str) -> dict:
|
| 355 |
-
original_text = (text or "").strip()
|
| 356 |
-
if not original_text:
|
| 357 |
-
return {
|
| 358 |
-
"text_for_analysis": text or "",
|
| 359 |
-
"source_language": "en",
|
| 360 |
-
"source_language_name": "English",
|
| 361 |
-
"was_translated": False,
|
| 362 |
-
"original_text": "",
|
| 363 |
-
"metadata":{},
|
| 364 |
-
}
|
| 365 |
-
|
| 366 |
-
detected = _heuristic_language_detection(original_text)
|
| 367 |
-
if gemini_service and getattr(gemini_service, "_initialized", False):
|
| 368 |
-
detected = gemini_service.detect_language(original_text)
|
| 369 |
-
|
| 370 |
-
source_code = str(detected.get("code", "en")).lower()
|
| 371 |
-
source_name = detected.get("name") or LANGUAGE_NAMES.get(source_code, source_code.upper())
|
| 372 |
-
if source_code in ("en", "eng"):
|
| 373 |
-
return {
|
| 374 |
-
"text_for_analysis": original_text,
|
| 375 |
-
"source_language": "en",
|
| 376 |
-
"source_language_name": "English",
|
| 377 |
-
"was_translated": False,
|
| 378 |
-
"original_text": original_text,
|
| 379 |
-
"metadata":{},
|
| 380 |
-
}
|
| 381 |
-
|
| 382 |
-
translated_text = original_text
|
| 383 |
-
if gemini_service and getattr(gemini_service, "_initialized", False):
|
| 384 |
-
translated_text = gemini_service.translate_to_english(original_text, source_name)
|
| 385 |
-
|
| 386 |
-
if not translated_text or translated_text.strip() == original_text:
|
| 387 |
-
return {
|
| 388 |
-
"text_for_analysis": original_text,
|
| 389 |
-
"source_language": source_code,
|
| 390 |
-
"source_language_name": source_name,
|
| 391 |
-
"was_translated": False,
|
| 392 |
-
"original_text": original_text,
|
| 393 |
-
"metadata":{},
|
| 394 |
-
}
|
| 395 |
-
|
| 396 |
-
return {
|
| 397 |
-
"text_for_analysis": translated_text.strip(),
|
| 398 |
-
"source_language": source_code,
|
| 399 |
-
"source_language_name": source_name,
|
| 400 |
-
"was_translated": True,
|
| 401 |
-
"original_text": original_text,
|
| 402 |
-
"metadata":{},
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
|
| 406 |
# ---------------------------------------------------------------------------
|
| 407 |
# Lifespan (startup / shutdown)
|
|
@@ -410,10 +264,6 @@ def detect_and_translate_ticket_text(text: str) -> dict:
|
|
| 410 |
async def lifespan(app: FastAPI):
|
| 411 |
"""Load all models at startup."""
|
| 412 |
print("[Startup] Loading AI models ...")
|
| 413 |
-
try:
|
| 414 |
-
redis_cache.connect()
|
| 415 |
-
except Exception as e:
|
| 416 |
-
print(f"[WARNING] Redis cache not available: {e}")
|
| 417 |
try:
|
| 418 |
classifier_service.load()
|
| 419 |
except FileNotFoundError as e:
|
|
@@ -430,39 +280,50 @@ async def lifespan(app: FastAPI):
|
|
| 430 |
rag_service.load()
|
| 431 |
except Exception as e:
|
| 432 |
print(f"[WARNING] RAG service not loaded: {e}")
|
| 433 |
-
try:
|
| 434 |
-
onnx_classifier.load()
|
| 435 |
-
except Exception as e:
|
| 436 |
-
print(f"[WARNING] ONNX classifier fallback not loaded: {e}")
|
| 437 |
|
| 438 |
if gemini_service:
|
| 439 |
print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
|
| 440 |
else:
|
| 441 |
print("[Startup] Gemini Service: NOT LOADED (Import failed)")
|
| 442 |
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
# Pre-load embedding model so first ticket save is fast
|
| 448 |
try:
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
print(f"[Startup] Semantic Duplicate Detection load error: {e}")
|
| 453 |
-
print(f"[Startup] SLA Engine: {'Initialized' if supabase else 'Offline (no DB)'}")
|
| 454 |
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
from backend.sla_checker import sla_checker_loop_async
|
| 458 |
-
asyncio.create_task(sla_checker_loop_async(supabase, interval_seconds=300))
|
| 459 |
-
print("[Startup] SLA background checker started (interval=300s)")
|
| 460 |
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
|
| 468 |
# ---------------------------------------------------------------------------
|
|
@@ -493,8 +354,6 @@ app.add_middleware(
|
|
| 493 |
allow_headers=["*"],
|
| 494 |
)
|
| 495 |
|
| 496 |
-
app.include_router(auth_cookie_router)
|
| 497 |
-
|
| 498 |
|
| 499 |
# ---------------------------------------------------------------------------
|
| 500 |
# Root & Health check
|
|
@@ -581,6 +440,12 @@ async def root():
|
|
| 581 |
"""
|
| 582 |
|
| 583 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
@app.get("/health", response_model=HealthResponse)
|
| 585 |
async def health_check():
|
| 586 |
return HealthResponse(
|
|
@@ -593,18 +458,29 @@ async def health_check():
|
|
| 593 |
@app.get("/ready", response_model=ReadinessResponse)
|
| 594 |
async def readiness_check():
|
| 595 |
require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
|
|
|
|
|
|
|
| 596 |
checks = {
|
| 597 |
"api": True,
|
| 598 |
"classifier_loaded": classifier_service._loaded,
|
| 599 |
"ner_loaded": ner_service._loaded,
|
| 600 |
-
"duplicate_index_loaded": duplicate_service.
|
| 601 |
-
"rag_loaded": rag_service.
|
| 602 |
}
|
| 603 |
if require_supabase:
|
| 604 |
checks["supabase_configured"] = supabase is not None
|
| 605 |
|
| 606 |
-
|
| 607 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
|
| 609 |
return JSONResponse(
|
| 610 |
status_code=503,
|
|
@@ -745,6 +621,31 @@ async def get_tickets(company_id: str | None = None):
|
|
| 745 |
res = query.execute()
|
| 746 |
return res.data
|
| 747 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
@app.post("/tickets/save")
|
| 749 |
async def save_ticket(request_body: TicketSaveRequest):
|
| 750 |
"""
|
|
@@ -755,80 +656,64 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 755 |
raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
|
| 756 |
|
| 757 |
logger = logging.getLogger(__name__)
|
| 758 |
-
final_data = request_body.model_dump()
|
| 759 |
-
original_subject = final_data.get("subject", "") or ""
|
| 760 |
-
original_description = final_data.get("description", "") or ""
|
| 761 |
-
|
| 762 |
-
# Detect language and translate subject/description into English before downstream routing/indexing.
|
| 763 |
-
translation_probe_text = (original_description.strip() or original_subject.strip())
|
| 764 |
-
translation_ctx = detect_and_translate_ticket_text(translation_probe_text)
|
| 765 |
-
metadata = final_data.get("metadata") or {}
|
| 766 |
-
if translation_ctx["was_translated"]:
|
| 767 |
-
translated_subject = gemini_service.translate_to_english(original_subject, translation_ctx["source_language_name"]) if original_subject else original_subject
|
| 768 |
-
translated_description = gemini_service.translate_to_english(original_description, translation_ctx["source_language_name"]) if original_description else original_description
|
| 769 |
-
final_data["subject"] = translated_subject or original_subject
|
| 770 |
-
final_data["description"] = translated_description or original_description
|
| 771 |
-
metadata["original_text"] = {
|
| 772 |
-
"subject": original_subject,
|
| 773 |
-
"description": original_description,
|
| 774 |
-
}
|
| 775 |
-
metadata["translation"] = {
|
| 776 |
-
"translated": bool(translation_ctx["was_translated"]),
|
| 777 |
-
"source_language": translation_ctx["source_language"],
|
| 778 |
-
"source_language_name": translation_ctx["source_language_name"],
|
| 779 |
-
}
|
| 780 |
-
final_data["metadata"] = metadata
|
| 781 |
-
|
| 782 |
-
# Backfill SLA deadlines/status when the client omits or sends empty values.
|
| 783 |
-
priority_key = str(final_data.get("priority") or "medium").lower().strip()
|
| 784 |
-
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
| 785 |
-
|
| 786 |
-
if not str(final_data.get("sla_breach_at") or "").strip():
|
| 787 |
-
final_data["sla_breach_at"] = compute_sla_breach_at(priority_key, now_utc)
|
| 788 |
-
|
| 789 |
-
if not str(final_data.get("sla_response_due_at") or "").strip():
|
| 790 |
-
policy = get_sla_policy(priority_key)
|
| 791 |
-
response_hours = max(1, int(round(float(policy["max_hours"]) * 0.25)))
|
| 792 |
-
response_due_at = now_utc + datetime.timedelta(hours=response_hours)
|
| 793 |
-
final_data["sla_response_due_at"] = response_due_at.isoformat()
|
| 794 |
-
|
| 795 |
-
if not str(final_data.get("sla_status") or "").strip():
|
| 796 |
-
final_data["sla_status"] = "ACTIVE"
|
| 797 |
-
# Resolve tenant linkage from user profile with authorization validation.
|
| 798 |
-
profile = {}
|
| 799 |
-
if request_body.user_id:
|
| 800 |
-
try:
|
| 801 |
-
profile_res = (
|
| 802 |
-
supabase.table("profiles")
|
| 803 |
-
.select("company_id, company")
|
| 804 |
-
.eq("id", request_body.user_id)
|
| 805 |
-
.single()
|
| 806 |
-
.execute()
|
| 807 |
-
)
|
| 808 |
-
profile = profile_res.data or {}
|
| 809 |
-
if not profile:
|
| 810 |
-
raise HTTPException(status_code=404, detail="User profile not found")
|
| 811 |
-
except HTTPException:
|
| 812 |
-
raise
|
| 813 |
-
except Exception as profile_error:
|
| 814 |
-
logger.error(f"Tenant resolution error for user {request_body.user_id}: {profile_error}")
|
| 815 |
-
raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
|
| 816 |
-
|
| 817 |
-
# Validate tenant consistency and authorization.
|
| 818 |
-
profile_company_id = profile.get("company_id")
|
| 819 |
-
if final_data.get("company_id"):
|
| 820 |
-
# User provided company_id: verify it matches their profile.
|
| 821 |
-
if profile_company_id and final_data["company_id"] != profile_company_id:
|
| 822 |
-
logger.warning(f"Tenant mismatch: user {request_body.user_id} attempted {final_data['company_id']}, assigned to {profile_company_id}")
|
| 823 |
-
raise HTTPException(status_code=403, detail="User not authorized for this tenant")
|
| 824 |
-
elif profile_company_id:
|
| 825 |
-
# Backfill company_id from profile.
|
| 826 |
-
final_data["company_id"] = profile_company_id
|
| 827 |
-
elif request_body.user_id:
|
| 828 |
-
# User has no tenant assignment.
|
| 829 |
-
raise HTTPException(status_code=400, detail="User has no tenant assignment")
|
| 830 |
-
|
| 831 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
# Backfill company name if missing.
|
| 833 |
if not final_data.get("company") and profile.get("company"):
|
| 834 |
final_data["company"] = profile["company"]
|
|
@@ -854,28 +739,19 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 854 |
"is_potential_duplicate": False,
|
| 855 |
"similarity": 0.0,
|
| 856 |
}
|
| 857 |
-
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 858 |
-
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 859 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
duplicate_check_result = None
|
| 864 |
-
try:
|
| 865 |
-
dupe_text = (request_body.description or request_body.subject or "").strip()
|
| 866 |
-
if dupe_text:
|
| 867 |
-
duplicate_check_result = await semantic_dupe_service.check_duplicate(
|
| 868 |
-
text=dupe_text,
|
| 869 |
-
company_id=final_data.get("company_id"),
|
| 870 |
-
)
|
| 871 |
-
if duplicate_check_result["is_duplicate"]:
|
| 872 |
-
logger.info(
|
| 873 |
-
f"[DUPLICATE] Ticket flagged as potential duplicate of "
|
| 874 |
-
f"{duplicate_check_result['duplicate_ticket_id']} "
|
| 875 |
-
f"(similarity: {duplicate_check_result['similarity']})"
|
| 876 |
-
)
|
| 877 |
-
except Exception as e:
|
| 878 |
-
logger.warning(f"[DUPLICATE] Semantic check error (non-fatal): {e}")
|
| 879 |
|
| 880 |
# --- Sanitize payload to only include valid Supabase DB columns ---
|
| 881 |
# Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
|
|
@@ -883,14 +759,13 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 883 |
VALID_TICKET_COLUMNS = {
|
| 884 |
"user_id", "subject", "description", "category", "subcategory",
|
| 885 |
"priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
|
| 886 |
-
"confidence", "image_url", "company", "company_id",
|
| 887 |
-
"sla_breach_at", "sla_response_due_at", "sla_status", "escalation_level", "metadata",
|
| 888 |
}
|
| 889 |
# Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
|
| 890 |
existing_metadata = final_data.get("metadata") or {}
|
| 891 |
extra_keys = (
|
| 892 |
"entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
|
| 893 |
-
"is_potential_duplicate", "parent_ticket_id"
|
| 894 |
)
|
| 895 |
for extra_key in extra_keys:
|
| 896 |
if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
|
|
@@ -907,29 +782,19 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 907 |
|
| 908 |
ticket_id = res.data[0]["id"]
|
| 909 |
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
try:
|
| 913 |
-
supabase.table("tickets").update({
|
| 914 |
-
"is_potential_duplicate": True,
|
| 915 |
-
"parent_ticket_id": duplicate_check_result["duplicate_ticket_id"],
|
| 916 |
-
}).eq("id", ticket_id).execute()
|
| 917 |
-
except Exception as e:
|
| 918 |
-
logger.warning(f"[DUPLICATE] Failed to link parent ticket: {e}")
|
| 919 |
-
|
| 920 |
-
# Index the new ticket's embedding for future duplicate checks
|
| 921 |
-
embedding_indexed = False
|
| 922 |
-
description_text = (request_body.description or "").strip()
|
| 923 |
-
subject_text = (request_body.subject or "").strip()
|
| 924 |
-
duplicate_text = description_text or subject_text
|
| 925 |
if duplicate_text:
|
| 926 |
try:
|
| 927 |
-
# Both: old in-memory index (for backward compat) and new pgvector index
|
| 928 |
duplicate_service.add_ticket(str(ticket_id), duplicate_text)
|
| 929 |
-
asyncio.create_task(semantic_dupe_service.index_ticket(ticket_id, duplicate_text))
|
| 930 |
-
embedding_indexed = True
|
| 931 |
except Exception as index_error:
|
| 932 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 933 |
|
| 934 |
# Add initial system diagnostic message
|
| 935 |
msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
|
|
@@ -947,14 +812,12 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 947 |
response = {
|
| 948 |
"status": "success",
|
| 949 |
"ticket_id": ticket_id,
|
| 950 |
-
"duplicate_indexed":
|
|
|
|
|
|
|
| 951 |
}
|
| 952 |
-
if
|
| 953 |
-
response["
|
| 954 |
-
response["parent_ticket_id"] = duplicate_check_result["duplicate_ticket_id"]
|
| 955 |
-
response["parent_subject"] = duplicate_check_result.get("parent_subject")
|
| 956 |
-
response["similarity"] = duplicate_check_result["similarity"]
|
| 957 |
-
response["candidates"] = duplicate_check_result.get("candidates", [])
|
| 958 |
return response
|
| 959 |
|
| 960 |
except Exception as e:
|
|
@@ -962,70 +825,17 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 962 |
raise HTTPException(status_code=500, detail=str(e))
|
| 963 |
|
| 964 |
@app.get("/tickets/{ticket_id}")
|
| 965 |
-
async def get_ticket_by_id(
|
| 966 |
-
request: Request,
|
| 967 |
-
ticket_id: str,
|
| 968 |
-
):
|
| 969 |
"""Fetch single persistent ticket."""
|
| 970 |
if not supabase:
|
| 971 |
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 972 |
-
|
| 973 |
-
# Guard route overlap where '/tickets/search' may be matched here first.
|
| 974 |
-
if ticket_id == "search":
|
| 975 |
-
return await search_tickets(
|
| 976 |
-
q=request.query_params.get("q", ""),
|
| 977 |
-
company_id=request.query_params.get("company_id"),
|
| 978 |
-
)
|
| 979 |
-
|
| 980 |
res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
|
| 981 |
if not res.data:
|
| 982 |
raise HTTPException(status_code=404, detail="Ticket not found")
|
| 983 |
return res.data
|
| 984 |
|
| 985 |
|
| 986 |
-
@app.get("/tickets/{ticket_id}/audit_logs", response_model=list[AuditLogRecord])
|
| 987 |
-
async def get_ticket_audit_logs(ticket_id: str, company_id: str):
|
| 988 |
-
"""Return a company-scoped chronological audit trail for a ticket."""
|
| 989 |
-
if not supabase:
|
| 990 |
-
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 991 |
-
|
| 992 |
-
try:
|
| 993 |
-
service = AuditLogService(supabase)
|
| 994 |
-
return service.get_ticket_audit_logs(ticket_id, company_id)
|
| 995 |
-
except AuditLogAccessError as err:
|
| 996 |
-
raise HTTPException(status_code=err.status_code, detail=err.detail)
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
@app.get("/tickets/search")
|
| 1000 |
-
async def search_tickets(q: str, company_id: str | None = None):
|
| 1001 |
-
"""Search tickets by query text, optionally scoped by company_id."""
|
| 1002 |
-
if not supabase:
|
| 1003 |
-
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 1004 |
-
query_text = (q or "").strip()
|
| 1005 |
-
if not query_text:
|
| 1006 |
-
raise HTTPException(status_code=400, detail="Query text is required")
|
| 1007 |
-
|
| 1008 |
-
try:
|
| 1009 |
-
rpc_res = supabase.rpc(
|
| 1010 |
-
"search_tickets",
|
| 1011 |
-
{"query_text": query_text, "company_id": company_id},
|
| 1012 |
-
).execute()
|
| 1013 |
-
return rpc_res.data or []
|
| 1014 |
-
except Exception:
|
| 1015 |
-
# Fallback for environments without RPC function support.
|
| 1016 |
-
fallback = supabase.table("tickets").select("*").order("created_at", desc=True).execute()
|
| 1017 |
-
rows = fallback.data or []
|
| 1018 |
-
lowered = query_text.lower()
|
| 1019 |
-
filtered = [
|
| 1020 |
-
row for row in rows
|
| 1021 |
-
if lowered in str(row.get("subject", "")).lower()
|
| 1022 |
-
or lowered in str(row.get("description", "")).lower()
|
| 1023 |
-
]
|
| 1024 |
-
if company_id:
|
| 1025 |
-
filtered = [row for row in filtered if row.get("company_id") == company_id]
|
| 1026 |
-
return filtered
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
@app.post("/tickets", response_model=TicketRecord)
|
| 1030 |
async def create_ticket(ticket: TicketRecord):
|
| 1031 |
"""Save a new ticket into the system."""
|
|
@@ -1064,6 +874,11 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
|
|
| 1064 |
Main endpoint for analyzing a new ticket using the cascade of local AI models.
|
| 1065 |
"""
|
| 1066 |
text = request_body.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
|
| 1068 |
# Grab client metadata
|
| 1069 |
client_ip = request.client.host if request.client else "unknown"
|
|
@@ -1096,8 +911,6 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1096 |
and duplicate check before committing to a ticket creation.
|
| 1097 |
"""
|
| 1098 |
text = request_body.text
|
| 1099 |
-
translation_ctx = detect_and_translate_ticket_text(text)
|
| 1100 |
-
text = translation_ctx["text_for_analysis"]
|
| 1101 |
print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
|
| 1102 |
settings = get_system_settings(request_body.company)
|
| 1103 |
confidence_threshold = settings["ai_confidence_threshold"]
|
|
@@ -1142,10 +955,6 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1142 |
is_potential_duplicate=False,
|
| 1143 |
parent_ticket_id=None,
|
| 1144 |
sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
|
| 1145 |
-
original_text=request_body.text,
|
| 1146 |
-
source_language=translation_ctx["source_language"],
|
| 1147 |
-
source_language_name=translation_ctx["source_language_name"],
|
| 1148 |
-
was_translated=translation_ctx["was_translated"],
|
| 1149 |
)
|
| 1150 |
|
| 1151 |
# --- Context & Environment ---
|
|
@@ -1178,7 +987,36 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1178 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1179 |
|
| 1180 |
# --- Classification ---
|
| 1181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1182 |
|
| 1183 |
timeline["ai_analyzed"] = get_now_ist()
|
| 1184 |
timeline["triaged"] = get_now_ist()
|
|
@@ -1192,10 +1030,21 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1192 |
timeline["metadata_harvested"] = get_now_ist()
|
| 1193 |
|
| 1194 |
# --- Duplicate detection ---
|
|
|
|
| 1195 |
try:
|
| 1196 |
-
dup_result =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1197 |
except Exception:
|
| 1198 |
-
dup_result = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1199 |
|
| 1200 |
# --- RAG Knowledge Base Check ---
|
| 1201 |
rag_match = None
|
|
@@ -1211,7 +1060,7 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1211 |
|
| 1212 |
# --- Reasoning ---
|
| 1213 |
decision_factors = []
|
| 1214 |
-
if classification["confidence"] >
|
| 1215 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1216 |
if entities:
|
| 1217 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
@@ -1221,6 +1070,14 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1221 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1222 |
|
| 1223 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1224 |
if classification["auto_resolve"]:
|
| 1225 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
| 1226 |
|
|
@@ -1230,10 +1087,8 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1230 |
if gemini_service and gemini_service._initialized:
|
| 1231 |
summary = gemini_service.get_summary(text)
|
| 1232 |
|
| 1233 |
-
# Convert priority to SLA
|
| 1234 |
-
|
| 1235 |
-
sla_hours = hours_map.get(classification["priority"], 72)
|
| 1236 |
-
sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
|
| 1237 |
|
| 1238 |
return TicketResponse(
|
| 1239 |
ticket_id=str(uuid.uuid4()), # Temporary ID
|
|
@@ -1246,21 +1101,17 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1246 |
entities=[EntityInfo(**e) for e in entities],
|
| 1247 |
duplicate_ticket=DuplicateInfo(**dup_result),
|
| 1248 |
confidence=classification["confidence"],
|
| 1249 |
-
needs_review=classification["confidence"] <
|
| 1250 |
reasoning=reasoning,
|
| 1251 |
decision_factors=decision_factors,
|
| 1252 |
image_description=gemini_analysis["image_description"],
|
| 1253 |
ocr_text=gemini_analysis["ocr_text"],
|
| 1254 |
-
highlights=
|
| 1255 |
timeline=timeline,
|
| 1256 |
env_metadata=env_metadata,
|
| 1257 |
is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
|
| 1258 |
parent_ticket_id=dup_result.get("parent_ticket_id"),
|
| 1259 |
-
sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
|
| 1260 |
-
original_text=translation_ctx["original_text"],
|
| 1261 |
-
source_language=translation_ctx["source_language"],
|
| 1262 |
-
source_language_name=translation_ctx["source_language_name"],
|
| 1263 |
-
was_translated=translation_ctx["was_translated"],
|
| 1264 |
)
|
| 1265 |
|
| 1266 |
@app.post("/ai/analyze_stream")
|
|
@@ -1279,7 +1130,11 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1279 |
"model_version": "3.0.0-PRO",
|
| 1280 |
"api_endpoint": "/ai/analyze_stream"
|
| 1281 |
}
|
| 1282 |
-
timeline = {"received": get_now_ist()}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1283 |
|
| 1284 |
# 1. Reading
|
| 1285 |
yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
|
|
@@ -1307,7 +1162,33 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1307 |
# 3. Classification
|
| 1308 |
yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
|
| 1309 |
await asyncio.sleep(0.2)
|
| 1310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1311 |
timeline["ai_analyzed"] = get_now_ist()
|
| 1312 |
timeline["triaged"] = get_now_ist()
|
| 1313 |
|
|
@@ -1315,9 +1196,20 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1315 |
yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
|
| 1316 |
await asyncio.sleep(0.2)
|
| 1317 |
try:
|
| 1318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
except Exception:
|
| 1320 |
-
dup_result = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1321 |
|
| 1322 |
# 5. RAG / Solutions
|
| 1323 |
yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
|
|
@@ -1333,7 +1225,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1333 |
pass
|
| 1334 |
|
| 1335 |
decision_factors = []
|
| 1336 |
-
if classification["confidence"] >
|
| 1337 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1338 |
if entities:
|
| 1339 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
@@ -1342,6 +1234,8 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1342 |
if rag_match:
|
| 1343 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1344 |
|
|
|
|
|
|
|
| 1345 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1346 |
if classification["auto_resolve"]:
|
| 1347 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
|
@@ -1351,9 +1245,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1351 |
if gemini_service and gemini_service._initialized:
|
| 1352 |
summary = gemini_service.get_summary(text)
|
| 1353 |
|
| 1354 |
-
|
| 1355 |
-
sla_hours = hours_map.get(classification["priority"], 72)
|
| 1356 |
-
sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
|
| 1357 |
|
| 1358 |
ticket_response_dict = {
|
| 1359 |
"ticket_id": str(uuid.uuid4()),
|
|
@@ -1366,15 +1258,17 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1366 |
"entities": [e for e in entities],
|
| 1367 |
"duplicate_ticket": dup_result,
|
| 1368 |
"confidence": classification["confidence"],
|
| 1369 |
-
"needs_review": classification["confidence"] <
|
| 1370 |
"reasoning": reasoning,
|
| 1371 |
"decision_factors": decision_factors,
|
| 1372 |
"image_description": gemini_analysis["image_description"],
|
| 1373 |
"ocr_text": gemini_analysis["ocr_text"],
|
| 1374 |
-
"highlights":
|
| 1375 |
"timeline": timeline,
|
| 1376 |
"env_metadata": env_metadata,
|
| 1377 |
-
"
|
|
|
|
|
|
|
| 1378 |
}
|
| 1379 |
|
| 1380 |
# 6. Final Result
|
|
@@ -1406,258 +1300,3 @@ async def analyze_ticket_v2(request: TicketRequest):
|
|
| 1406 |
}
|
| 1407 |
except Exception as e:
|
| 1408 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
# ---------------------------------------------------------------------------
|
| 1412 |
-
# SLA Engine Endpoints
|
| 1413 |
-
# ---------------------------------------------------------------------------
|
| 1414 |
-
|
| 1415 |
-
class SLAStatsResponse(BaseModel):
|
| 1416 |
-
total: int = 0
|
| 1417 |
-
active: int = 0
|
| 1418 |
-
breached: int = 0
|
| 1419 |
-
warning: int = 0
|
| 1420 |
-
met: int = 0
|
| 1421 |
-
breach_rate: float = 0.0
|
| 1422 |
-
by_priority: dict = {}
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
@app.get("/sla/stats", response_model=SLAStatsResponse)
|
| 1426 |
-
async def sla_stats():
|
| 1427 |
-
"""Get aggregated SLA dashboard statistics across all tickets."""
|
| 1428 |
-
if not supabase:
|
| 1429 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1430 |
-
stats = await sla_engine.get_dashboard_stats()
|
| 1431 |
-
if "error" in stats:
|
| 1432 |
-
raise HTTPException(status_code=500, detail=stats["error"])
|
| 1433 |
-
return stats
|
| 1434 |
-
|
| 1435 |
-
|
| 1436 |
-
class SLATicketInfo(BaseModel):
|
| 1437 |
-
id: str
|
| 1438 |
-
ticket_id: str | None = None
|
| 1439 |
-
subject: str | None = None
|
| 1440 |
-
summary: str | None = None
|
| 1441 |
-
priority: str = "medium"
|
| 1442 |
-
status: str | None = None
|
| 1443 |
-
assigned_team: str | None = None
|
| 1444 |
-
sla_status: str = "active"
|
| 1445 |
-
escalation_level: int = 0
|
| 1446 |
-
remaining_seconds: int = 0
|
| 1447 |
-
created_at: str | None = None
|
| 1448 |
-
sla_breach_at: str | None = None
|
| 1449 |
-
sla_warning_at: str | None = None
|
| 1450 |
-
last_escalated_at: str | None = None
|
| 1451 |
-
|
| 1452 |
-
|
| 1453 |
-
@app.get("/sla/tickets")
|
| 1454 |
-
async def sla_tickets(
|
| 1455 |
-
status: str | None = None,
|
| 1456 |
-
priority: str | None = None,
|
| 1457 |
-
limit: int = 100,
|
| 1458 |
-
offset: int = 0,
|
| 1459 |
-
):
|
| 1460 |
-
"""
|
| 1461 |
-
List tickets with SLA status. Filter by sla_status and/or priority.
|
| 1462 |
-
"""
|
| 1463 |
-
if not supabase:
|
| 1464 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1465 |
-
|
| 1466 |
-
query = (
|
| 1467 |
-
supabase.table("tickets")
|
| 1468 |
-
.select("id, ticket_id, subject, summary, priority, status, assigned_team, sla_status, escalation_level, remaining_seconds, created_at, sla_breach_at, sla_warning_at, last_escalated_at")
|
| 1469 |
-
.order("created_at", desc=True)
|
| 1470 |
-
)
|
| 1471 |
-
|
| 1472 |
-
if status and status != "all":
|
| 1473 |
-
query = query.eq("sla_status", status)
|
| 1474 |
-
if priority and priority != "all":
|
| 1475 |
-
query = query.eq("priority", priority.capitalize())
|
| 1476 |
-
|
| 1477 |
-
query = query.range(offset, offset + limit - 1)
|
| 1478 |
-
res = query.execute()
|
| 1479 |
-
return {"tickets": res.data or [], "total": len(res.data or [])}
|
| 1480 |
-
|
| 1481 |
-
|
| 1482 |
-
class EscalationLogEntry(BaseModel):
|
| 1483 |
-
id: str
|
| 1484 |
-
ticket_id: str | None = None
|
| 1485 |
-
ticket_subject: str = ""
|
| 1486 |
-
priority: str = "medium"
|
| 1487 |
-
sla_status: str = ""
|
| 1488 |
-
escalation_level: int = 0
|
| 1489 |
-
remaining_seconds: int = 0
|
| 1490 |
-
assigned_team: str = ""
|
| 1491 |
-
notification_channels: list = []
|
| 1492 |
-
triggered_at: str | None = None
|
| 1493 |
-
resolved_at: str | None = None
|
| 1494 |
-
notes: str = ""
|
| 1495 |
-
|
| 1496 |
-
|
| 1497 |
-
@app.get("/sla/escalations")
|
| 1498 |
-
async def sla_escalations(limit: int = 50, offset: int = 0):
|
| 1499 |
-
"""Fetch escalation log history."""
|
| 1500 |
-
if not supabase:
|
| 1501 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1502 |
-
|
| 1503 |
-
try:
|
| 1504 |
-
res = (
|
| 1505 |
-
supabase.table("escalation_logs")
|
| 1506 |
-
.select("*")
|
| 1507 |
-
.order("triggered_at", desc=True)
|
| 1508 |
-
.range(offset, offset + limit - 1)
|
| 1509 |
-
.execute()
|
| 1510 |
-
)
|
| 1511 |
-
return {"escalations": res.data or [], "total": len(res.data or [])}
|
| 1512 |
-
except Exception as e:
|
| 1513 |
-
# Table might not exist yet
|
| 1514 |
-
print(f"[SLA] Escalation logs query failed: {e}")
|
| 1515 |
-
return {"escalations": [], "total": 0}
|
| 1516 |
-
|
| 1517 |
-
|
| 1518 |
-
class SLAPolicyInfo(BaseModel):
|
| 1519 |
-
id: str
|
| 1520 |
-
priority: str
|
| 1521 |
-
max_hours: int
|
| 1522 |
-
warning_pct: float
|
| 1523 |
-
auto_escalate: bool
|
| 1524 |
-
l2_after_minutes: int
|
| 1525 |
-
l3_after_minutes: int
|
| 1526 |
-
|
| 1527 |
-
|
| 1528 |
-
@app.get("/sla/policies")
|
| 1529 |
-
async def sla_policies():
|
| 1530 |
-
"""Get configured SLA policies."""
|
| 1531 |
-
if not supabase:
|
| 1532 |
-
# Return defaults from code
|
| 1533 |
-
policies = []
|
| 1534 |
-
for pri, cfg in sla_engine.SLA_POLICIES.items() if hasattr(sla_engine, 'SLA_POLICIES') else SLA_POLICIES.items():
|
| 1535 |
-
policies.append({
|
| 1536 |
-
"priority": pri,
|
| 1537 |
-
"max_hours": cfg["max_hours"],
|
| 1538 |
-
"warning_pct": cfg["warning_pct"],
|
| 1539 |
-
"auto_escalate": cfg["auto_escalate_on_breach"],
|
| 1540 |
-
"l2_after_minutes": cfg["l2_escalation_mins"],
|
| 1541 |
-
"l3_after_minutes": cfg["l3_escalation_mins"],
|
| 1542 |
-
})
|
| 1543 |
-
return {"policies": policies}
|
| 1544 |
-
|
| 1545 |
-
try:
|
| 1546 |
-
res = supabase.table("sla_policies").select("*").execute()
|
| 1547 |
-
return {"policies": res.data or []}
|
| 1548 |
-
except Exception as e:
|
| 1549 |
-
print(f"[SLA] Policies query failed: {e}")
|
| 1550 |
-
return {"policies": []}
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
@app.post("/sla/check")
|
| 1554 |
-
async def trigger_sla_check():
|
| 1555 |
-
"""Manually trigger an SLA evaluation cycle (admin)."""
|
| 1556 |
-
if not supabase:
|
| 1557 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1558 |
-
|
| 1559 |
-
asyncio.create_task(sla_engine.check_all_active_tickets())
|
| 1560 |
-
return {"status": "triggered", "message": "SLA check cycle started in background"}
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
# ---------------------------------------------------------------------------
|
| 1564 |
-
# Semantic Duplicate Detection Endpoints
|
| 1565 |
-
# ---------------------------------------------------------------------------
|
| 1566 |
-
|
| 1567 |
-
@app.post("/ai/check_duplicate")
|
| 1568 |
-
async def check_duplicate_endpoint(
|
| 1569 |
-
body: TicketRequest,
|
| 1570 |
-
company_id: str | None = None,
|
| 1571 |
-
):
|
| 1572 |
-
"""
|
| 1573 |
-
Check a ticket text for potential duplicates using semantic vector search.
|
| 1574 |
-
Returns top candidates with similarity scores.
|
| 1575 |
-
"""
|
| 1576 |
-
text = (body.text or "").strip()
|
| 1577 |
-
if not text:
|
| 1578 |
-
raise HTTPException(status_code=400, detail="No text provided")
|
| 1579 |
-
|
| 1580 |
-
threshold = body.duplicate_sensitivity if hasattr(body, 'duplicate_sensitivity') else None
|
| 1581 |
-
result = await semantic_dupe_service.check_duplicate(
|
| 1582 |
-
text=text,
|
| 1583 |
-
company_id=company_id or body.company,
|
| 1584 |
-
threshold=threshold,
|
| 1585 |
-
)
|
| 1586 |
-
return result
|
| 1587 |
-
|
| 1588 |
-
|
| 1589 |
-
@app.post("/ai/reindex_embeddings")
|
| 1590 |
-
async def reindex_embeddings():
|
| 1591 |
-
"""Re-generate vector embeddings for all tickets."""
|
| 1592 |
-
result = await semantic_dupe_service.reindex_all()
|
| 1593 |
-
return result
|
| 1594 |
-
|
| 1595 |
-
|
| 1596 |
-
@app.get("/system/settings")
|
| 1597 |
-
async def get_system_settings_endpoint():
|
| 1598 |
-
"""Fetch all system settings."""
|
| 1599 |
-
if not supabase:
|
| 1600 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1601 |
-
try:
|
| 1602 |
-
res = supabase.table("system_settings").select("*").execute()
|
| 1603 |
-
settings = {}
|
| 1604 |
-
for row in res.data or []:
|
| 1605 |
-
settings[row["key"]] = row["value"]
|
| 1606 |
-
return settings
|
| 1607 |
-
except Exception as e:
|
| 1608 |
-
logger.warning(f"[SETTINGS] Query failed: {e}")
|
| 1609 |
-
return {}
|
| 1610 |
-
|
| 1611 |
-
|
| 1612 |
-
@app.patch("/system/settings")
|
| 1613 |
-
async def update_system_settings(body: dict):
|
| 1614 |
-
"""Update a specific system setting."""
|
| 1615 |
-
if not supabase:
|
| 1616 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1617 |
-
key = body.get("key")
|
| 1618 |
-
value = body.get("value")
|
| 1619 |
-
if not key or value is None:
|
| 1620 |
-
raise HTTPException(status_code=400, detail="key and value required")
|
| 1621 |
-
try:
|
| 1622 |
-
supabase.table("system_settings").upsert({
|
| 1623 |
-
"key": key,
|
| 1624 |
-
"value": value,
|
| 1625 |
-
"updated_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 1626 |
-
}).execute()
|
| 1627 |
-
return {"status": "updated", "key": key}
|
| 1628 |
-
except Exception as e:
|
| 1629 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 1630 |
-
|
| 1631 |
-
|
| 1632 |
-
@app.get("/sla/tickets/{ticket_id}")
|
| 1633 |
-
async def sla_ticket_detail(ticket_id: str):
|
| 1634 |
-
"""Get detailed SLA info for a specific ticket."""
|
| 1635 |
-
if not supabase:
|
| 1636 |
-
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1637 |
-
|
| 1638 |
-
# Fetch ticket
|
| 1639 |
-
res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
|
| 1640 |
-
if not res.data:
|
| 1641 |
-
raise HTTPException(status_code=404, detail="Ticket not found")
|
| 1642 |
-
|
| 1643 |
-
ticket = res.data
|
| 1644 |
-
result = sla_engine.evaluate_ticket(ticket)
|
| 1645 |
-
|
| 1646 |
-
# Fetch escalation history for this ticket
|
| 1647 |
-
try:
|
| 1648 |
-
esc_res = (
|
| 1649 |
-
supabase.table("escalation_logs")
|
| 1650 |
-
.select("*")
|
| 1651 |
-
.eq("ticket_id", ticket_id)
|
| 1652 |
-
.order("triggered_at", desc=True)
|
| 1653 |
-
.execute()
|
| 1654 |
-
)
|
| 1655 |
-
escalations = esc_res.data or []
|
| 1656 |
-
except Exception:
|
| 1657 |
-
escalations = []
|
| 1658 |
-
|
| 1659 |
-
return {
|
| 1660 |
-
"ticket": ticket,
|
| 1661 |
-
"sla_evaluation": result,
|
| 1662 |
-
"escalations": escalations,
|
| 1663 |
-
}
|
|
|
|
| 8 |
import sys
|
| 9 |
import uuid
|
| 10 |
import json
|
|
|
|
| 11 |
import datetime
|
| 12 |
import traceback
|
| 13 |
import warnings
|
|
|
|
| 24 |
from slowapi.util import get_remote_address
|
| 25 |
from slowapi.errors import RateLimitExceeded
|
| 26 |
from fastapi.middleware.cors import CORSMiddleware
|
| 27 |
+
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
| 28 |
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
| 29 |
from fastapi.encoders import jsonable_encoder
|
| 30 |
import asyncio
|
| 31 |
from pathlib import Path
|
|
|
|
| 36 |
env_path = Path(__file__).parent / '.env'
|
| 37 |
load_dotenv(dotenv_path=env_path)
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# Initialize Supabase Client (Service Role for backend bypass)
|
| 40 |
try:
|
| 41 |
from supabase import create_client, Client
|
|
|
|
| 57 |
from backend.services.classifier_service import ClassifierService
|
| 58 |
from backend.services.classifier_v2 import classifier_v2
|
| 59 |
from backend.services.classifier_v3 import classifier_v3 # V3 Power Model
|
|
|
|
|
|
|
| 60 |
from backend.services.ner_service import NERService
|
| 61 |
from backend.services.duplicate_service import DuplicateService
|
| 62 |
from backend.services.rag_service import RagService
|
| 63 |
+
from backend.services.sla_service import (
|
| 64 |
+
calculate_sla_breach_at,
|
| 65 |
+
calculate_sla_response_at,
|
| 66 |
+
classify_sla_status,
|
| 67 |
+
load as load_sla_service,
|
| 68 |
+
run_sla_escalation_loop,
|
| 69 |
+
)
|
| 70 |
|
| 71 |
|
| 72 |
# ---------------------------------------------------------------------------
|
|
|
|
| 115 |
duplicate_result["parent_ticket_id"] = duplicate_result.get("duplicate_ticket_id")
|
| 116 |
duplicate_result["is_potential_duplicate"] = duplicate_result.get("is_duplicate", False)
|
| 117 |
return duplicate_result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
class TicketRequest(BaseModel):
|
| 119 |
text: str
|
| 120 |
image_base64: str = ""
|
| 121 |
image_text: str = "" # Keep for backward compatibility
|
| 122 |
user_id: str | None = None
|
| 123 |
company: str | None = None
|
| 124 |
+
company_id: str | None = None
|
| 125 |
image_url: str | None = None
|
| 126 |
confidence_threshold: float = 0.20
|
| 127 |
duplicate_sensitivity: float = 0.85
|
|
|
|
| 141 |
image_url: str | None = None
|
| 142 |
company: str | None = None
|
| 143 |
company_id: str | None = None
|
| 144 |
+
description_vector: list[float] | None = None
|
| 145 |
+
is_potential_duplicate: bool = False
|
| 146 |
+
parent_ticket_id: str | None = None
|
| 147 |
+
sla_response_due_at: str | None = None
|
| 148 |
sla_breach_at: str
|
| 149 |
sla_status: str | None = None
|
| 150 |
escalation_level: int = 0
|
|
|
|
| 160 |
class DuplicateInfo(BaseModel):
|
| 161 |
is_duplicate: bool
|
| 162 |
duplicate_ticket_id: str | None = None
|
| 163 |
+
parent_ticket_id: str | None = None
|
| 164 |
+
is_potential_duplicate: bool = False
|
| 165 |
similarity: float = 0.0
|
| 166 |
|
| 167 |
|
|
|
|
| 183 |
entities: list[EntityInfo]
|
| 184 |
duplicate_ticket: DuplicateInfo
|
| 185 |
confidence: float
|
| 186 |
+
is_potential_duplicate: bool = False
|
| 187 |
+
parent_ticket_id: str | None = None
|
| 188 |
needs_review: bool = False
|
| 189 |
reasoning: str = ""
|
| 190 |
decision_factors: list[str] = []
|
|
|
|
| 194 |
timeline: dict = {} # Map of step_name: timestamp
|
| 195 |
env_metadata: dict = {} # IP, Hostname, Browser/OS
|
| 196 |
sla_breach_at: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
version: str = "2.1.0-Neural-Diagnostic"
|
| 198 |
|
| 199 |
|
|
|
|
| 221 |
timeline: dict = {} # Milestones: created, analyzed, triaged, routed, in_progress, resolved
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
# --- In-Memory Database (to be replaced with SQL later) ---
|
| 225 |
TICKETS_DB: list[TicketRecord] = []
|
| 226 |
|
|
|
|
| 243 |
ner_service = NERService()
|
| 244 |
duplicate_service = DuplicateService()
|
| 245 |
rag_service = RagService()
|
|
|
|
|
|
|
| 246 |
|
| 247 |
try:
|
| 248 |
from backend.services.gemini_service import GeminiService
|
|
|
|
| 256 |
except ImportError:
|
| 257 |
ocr_service = None
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
# ---------------------------------------------------------------------------
|
| 261 |
# Lifespan (startup / shutdown)
|
|
|
|
| 264 |
async def lifespan(app: FastAPI):
|
| 265 |
"""Load all models at startup."""
|
| 266 |
print("[Startup] Loading AI models ...")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
try:
|
| 268 |
classifier_service.load()
|
| 269 |
except FileNotFoundError as e:
|
|
|
|
| 280 |
rag_service.load()
|
| 281 |
except Exception as e:
|
| 282 |
print(f"[WARNING] RAG service not loaded: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
if gemini_service:
|
| 285 |
print(f"[Startup] Gemini Service: {'Initialized' if gemini_service._initialized else 'FAILED (Key missing or SDK error)'}")
|
| 286 |
else:
|
| 287 |
print("[Startup] Gemini Service: NOT LOADED (Import failed)")
|
| 288 |
|
| 289 |
+
print("[Startup] Classifier V2 Shadow: Ready.")
|
| 290 |
+
print("[Startup] Ready.")
|
| 291 |
+
# Strict health checks: fail loudly when core model assets are unavailable.
|
| 292 |
+
# Set ALLOW_DEGRADED_STARTUP=1 to permit degraded startup for local/dev convenience.
|
|
|
|
| 293 |
try:
|
| 294 |
+
strict_mode = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") != "1"
|
| 295 |
+
except Exception:
|
| 296 |
+
strict_mode = True
|
|
|
|
|
|
|
| 297 |
|
| 298 |
+
classifier_loaded_flag = getattr(classifier_service, "_loaded", False)
|
| 299 |
+
ner_loaded_flag = getattr(ner_service, "_loaded", False)
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
+
if strict_mode and not classifier_loaded_flag:
|
| 302 |
+
raise RuntimeError("[Startup-FATAL] Classifier assets not loaded. Set ALLOW_DEGRADED_STARTUP=1 to bypass.")
|
| 303 |
+
|
| 304 |
+
sla_task = None
|
| 305 |
+
try:
|
| 306 |
+
if supabase and os.environ.get("SLA_ESCALATION_ENABLED", "true").lower() == "true":
|
| 307 |
+
notification_router = None
|
| 308 |
+
try:
|
| 309 |
+
from backend.services.notification_routing import load as load_notification_router
|
| 310 |
+
notification_router = load_notification_router()
|
| 311 |
+
except Exception as e:
|
| 312 |
+
print(f"[WARNING] Notification router not loaded for SLA service: {e}")
|
| 313 |
+
sla_service = load_sla_service(supabase, notification_router)
|
| 314 |
+
interval = int(os.environ.get("SLA_ESCALATION_INTERVAL_SECONDS", "300"))
|
| 315 |
+
sla_task = asyncio.create_task(run_sla_escalation_loop(sla_service, interval_seconds=interval))
|
| 316 |
+
print(f"[Startup] SLA escalation loop enabled ({interval}s interval).")
|
| 317 |
+
|
| 318 |
+
yield
|
| 319 |
+
finally:
|
| 320 |
+
if sla_task:
|
| 321 |
+
sla_task.cancel()
|
| 322 |
+
try:
|
| 323 |
+
await sla_task
|
| 324 |
+
except asyncio.CancelledError:
|
| 325 |
+
pass
|
| 326 |
+
print("[Shutdown] Cleaning up ...")
|
| 327 |
|
| 328 |
|
| 329 |
# ---------------------------------------------------------------------------
|
|
|
|
| 354 |
allow_headers=["*"],
|
| 355 |
)
|
| 356 |
|
|
|
|
|
|
|
| 357 |
|
| 358 |
# ---------------------------------------------------------------------------
|
| 359 |
# Root & Health check
|
|
|
|
| 440 |
"""
|
| 441 |
|
| 442 |
|
| 443 |
+
@app.get("/metrics")
|
| 444 |
+
async def metrics():
|
| 445 |
+
"""Prometheus scrape endpoint — exposes AI inference latency, request counts, and tokens."""
|
| 446 |
+
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
| 447 |
+
|
| 448 |
+
|
| 449 |
@app.get("/health", response_model=HealthResponse)
|
| 450 |
async def health_check():
|
| 451 |
return HealthResponse(
|
|
|
|
| 458 |
@app.get("/ready", response_model=ReadinessResponse)
|
| 459 |
async def readiness_check():
|
| 460 |
require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
|
| 461 |
+
allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
|
| 462 |
+
|
| 463 |
checks = {
|
| 464 |
"api": True,
|
| 465 |
"classifier_loaded": classifier_service._loaded,
|
| 466 |
"ner_loaded": ner_service._loaded,
|
| 467 |
+
"duplicate_index_loaded": duplicate_service.is_available(),
|
| 468 |
+
"rag_loaded": rag_service.is_available(),
|
| 469 |
}
|
| 470 |
if require_supabase:
|
| 471 |
checks["supabase_configured"] = supabase is not None
|
| 472 |
|
| 473 |
+
# In degraded mode, duplicate and RAG services are optional
|
| 474 |
+
if allow_degraded:
|
| 475 |
+
required_checks = {k: v for k, v in checks.items() if k not in ["duplicate_index_loaded", "rag_loaded"]}
|
| 476 |
+
all_required_pass = all(required_checks.values())
|
| 477 |
+
|
| 478 |
+
if all_required_pass:
|
| 479 |
+
return ReadinessResponse(status="ready", checks=checks)
|
| 480 |
+
else:
|
| 481 |
+
# Strict mode: all checks must pass
|
| 482 |
+
if all(checks.values()):
|
| 483 |
+
return ReadinessResponse(status="ready", checks=checks)
|
| 484 |
|
| 485 |
return JSONResponse(
|
| 486 |
status_code=503,
|
|
|
|
| 621 |
res = query.execute()
|
| 622 |
return res.data
|
| 623 |
|
| 624 |
+
@app.get("/tickets/search")
|
| 625 |
+
async def search_tickets(q: str | None = None, company_id: str | None = None, limit: int = 50, offset: int = 0):
|
| 626 |
+
"""Search tickets using tenant-safe full-text search."""
|
| 627 |
+
if not supabase:
|
| 628 |
+
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 629 |
+
|
| 630 |
+
if not q:
|
| 631 |
+
raise HTTPException(status_code=400, detail="Search query is required")
|
| 632 |
+
if not company_id:
|
| 633 |
+
raise HTTPException(status_code=400, detail="company_id is required for tenant-safe search")
|
| 634 |
+
|
| 635 |
+
try:
|
| 636 |
+
result = supabase.rpc(
|
| 637 |
+
"search_tickets",
|
| 638 |
+
{
|
| 639 |
+
"query_text": q,
|
| 640 |
+
"company_id": company_id,
|
| 641 |
+
"limit_rows": limit,
|
| 642 |
+
"offset_rows": offset,
|
| 643 |
+
},
|
| 644 |
+
).execute()
|
| 645 |
+
return result.data or []
|
| 646 |
+
except Exception as e:
|
| 647 |
+
raise HTTPException(status_code=500, detail=f"Search failed: {e}")
|
| 648 |
+
|
| 649 |
@app.post("/tickets/save")
|
| 650 |
async def save_ticket(request_body: TicketSaveRequest):
|
| 651 |
"""
|
|
|
|
| 656 |
raise HTTPException(status_code=500, detail="Supabase connection not initialized.")
|
| 657 |
|
| 658 |
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
try:
|
| 660 |
+
final_data = request_body.dict()
|
| 661 |
+
|
| 662 |
+
# Resolve tenant linkage from user profile with authorization validation.
|
| 663 |
+
profile = {}
|
| 664 |
+
if request_body.user_id:
|
| 665 |
+
try:
|
| 666 |
+
profile_res = (
|
| 667 |
+
supabase.table("profiles")
|
| 668 |
+
.select("company_id, company")
|
| 669 |
+
.eq("id", request_body.user_id)
|
| 670 |
+
.single()
|
| 671 |
+
.execute()
|
| 672 |
+
)
|
| 673 |
+
profile = profile_res.data or {}
|
| 674 |
+
if not profile:
|
| 675 |
+
raise HTTPException(status_code=404, detail="User profile not found")
|
| 676 |
+
|
| 677 |
+
# SELF-HEALING: If company_id is null in database but company name exists, resolve it!
|
| 678 |
+
if not profile.get("company_id") and profile.get("company"):
|
| 679 |
+
try:
|
| 680 |
+
comp_name = profile.get("company").strip()
|
| 681 |
+
comp_res = (
|
| 682 |
+
supabase.table("companies")
|
| 683 |
+
.select("id")
|
| 684 |
+
.ilike("name", comp_name)
|
| 685 |
+
.execute()
|
| 686 |
+
)
|
| 687 |
+
if comp_res.data:
|
| 688 |
+
resolved_company_id = comp_res.data[0]["id"]
|
| 689 |
+
# Backfill the profile table in real-time
|
| 690 |
+
supabase.table("profiles").update({"company_id": resolved_company_id}).eq("id", request_body.user_id).execute()
|
| 691 |
+
profile["company_id"] = resolved_company_id
|
| 692 |
+
logger.info(f"[SELF-HEALING] Backfilled company_id={resolved_company_id} for user={request_body.user_id}")
|
| 693 |
+
except Exception as healing_err:
|
| 694 |
+
logger.warning(f"[SELF-HEALING WARNING] Failed to backfill company_id: {healing_err}")
|
| 695 |
+
except HTTPException:
|
| 696 |
+
raise
|
| 697 |
+
except Exception as profile_error:
|
| 698 |
+
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 699 |
+
logger.error(f"Tenant resolution error for user {user_hash}: {profile_error}")
|
| 700 |
+
raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
|
| 701 |
+
|
| 702 |
+
# Validate tenant consistency and authorization.
|
| 703 |
+
profile_company_id = profile.get("company_id")
|
| 704 |
+
if final_data.get("company_id"):
|
| 705 |
+
# User provided company_id: verify it matches their profile.
|
| 706 |
+
if profile_company_id and final_data["company_id"] != profile_company_id:
|
| 707 |
+
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 708 |
+
logger.warning(f"Tenant mismatch: user {user_hash} attempted {final_data['company_id']}, assigned to {profile_company_id}")
|
| 709 |
+
raise HTTPException(status_code=403, detail="User not authorized for this tenant")
|
| 710 |
+
elif profile_company_id:
|
| 711 |
+
# Backfill company_id from profile.
|
| 712 |
+
final_data["company_id"] = profile_company_id
|
| 713 |
+
elif request_body.user_id:
|
| 714 |
+
# User has no tenant assignment.
|
| 715 |
+
raise HTTPException(status_code=400, detail="User has no tenant assignment")
|
| 716 |
+
|
| 717 |
# Backfill company name if missing.
|
| 718 |
if not final_data.get("company") and profile.get("company"):
|
| 719 |
final_data["company"] = profile["company"]
|
|
|
|
| 739 |
"is_potential_duplicate": False,
|
| 740 |
"similarity": 0.0,
|
| 741 |
}
|
|
|
|
|
|
|
| 742 |
|
| 743 |
+
if duplicate_text:
|
| 744 |
+
duplicate_result = detect_semantic_duplicate(
|
| 745 |
+
duplicate_text,
|
| 746 |
+
company_id=final_data.get("company_id"),
|
| 747 |
+
threshold=duplicate_threshold,
|
| 748 |
+
)
|
| 749 |
+
final_data["description_vector"] = duplicate_service.generate_embedding(duplicate_text)
|
| 750 |
+
else:
|
| 751 |
+
final_data["description_vector"] = None
|
| 752 |
|
| 753 |
+
final_data["is_potential_duplicate"] = duplicate_result.get("is_potential_duplicate", False)
|
| 754 |
+
final_data["parent_ticket_id"] = duplicate_result.get("parent_ticket_id")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
|
| 756 |
# --- Sanitize payload to only include valid Supabase DB columns ---
|
| 757 |
# Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
|
|
|
|
| 759 |
VALID_TICKET_COLUMNS = {
|
| 760 |
"user_id", "subject", "description", "category", "subcategory",
|
| 761 |
"priority", "assigned_team", "status", "auto_resolve", "is_duplicate",
|
| 762 |
+
"confidence", "image_url", "company", "company_id", "sla_breach_at", "metadata",
|
|
|
|
| 763 |
}
|
| 764 |
# Merge any extra telemetry and SLA/duplicate fields into metadata before filtering
|
| 765 |
existing_metadata = final_data.get("metadata") or {}
|
| 766 |
extra_keys = (
|
| 767 |
"entities", "solution_steps", "ocr_text", "needs_review", "routing_confidence",
|
| 768 |
+
"is_potential_duplicate", "parent_ticket_id", "sla_response_due_at", "sla_status", "escalation_level"
|
| 769 |
)
|
| 770 |
for extra_key in extra_keys:
|
| 771 |
if extra_key in final_data and final_data[extra_key] not in (None, "", [], {}):
|
|
|
|
| 782 |
|
| 783 |
ticket_id = res.data[0]["id"]
|
| 784 |
|
| 785 |
+
duplicate_indexed = True
|
| 786 |
+
duplicate_index_warning = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
if duplicate_text:
|
| 788 |
try:
|
|
|
|
| 789 |
duplicate_service.add_ticket(str(ticket_id), duplicate_text)
|
|
|
|
|
|
|
| 790 |
except Exception as index_error:
|
| 791 |
+
duplicate_indexed = False
|
| 792 |
+
duplicate_index_warning = "Duplicate index update failed."
|
| 793 |
+
print(f"[WARNING] {duplicate_index_warning} ticket_id={ticket_id} error={index_error}")
|
| 794 |
+
else:
|
| 795 |
+
duplicate_indexed = False
|
| 796 |
+
duplicate_index_warning = "Duplicate index update skipped: no description or subject text was provided."
|
| 797 |
+
print(f"[WARNING] {duplicate_index_warning}")
|
| 798 |
|
| 799 |
# Add initial system diagnostic message
|
| 800 |
msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
|
|
|
|
| 812 |
response = {
|
| 813 |
"status": "success",
|
| 814 |
"ticket_id": ticket_id,
|
| 815 |
+
"duplicate_indexed": duplicate_indexed,
|
| 816 |
+
"is_potential_duplicate": final_data["is_potential_duplicate"],
|
| 817 |
+
"parent_ticket_id": final_data["parent_ticket_id"],
|
| 818 |
}
|
| 819 |
+
if duplicate_index_warning:
|
| 820 |
+
response["duplicate_index_warning"] = duplicate_index_warning
|
|
|
|
|
|
|
|
|
|
|
|
|
| 821 |
return response
|
| 822 |
|
| 823 |
except Exception as e:
|
|
|
|
| 825 |
raise HTTPException(status_code=500, detail=str(e))
|
| 826 |
|
| 827 |
@app.get("/tickets/{ticket_id}")
|
| 828 |
+
async def get_ticket_by_id(ticket_id: str):
|
|
|
|
|
|
|
|
|
|
| 829 |
"""Fetch single persistent ticket."""
|
| 830 |
if not supabase:
|
| 831 |
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 832 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
|
| 834 |
if not res.data:
|
| 835 |
raise HTTPException(status_code=404, detail="Ticket not found")
|
| 836 |
return res.data
|
| 837 |
|
| 838 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
@app.post("/tickets", response_model=TicketRecord)
|
| 840 |
async def create_ticket(ticket: TicketRecord):
|
| 841 |
"""Save a new ticket into the system."""
|
|
|
|
| 874 |
Main endpoint for analyzing a new ticket using the cascade of local AI models.
|
| 875 |
"""
|
| 876 |
text = request_body.text
|
| 877 |
+
|
| 878 |
+
settings = get_system_settings(request_body.company_id)
|
| 879 |
+
confidence_threshold = settings["ai_confidence_threshold"]
|
| 880 |
+
duplicate_sensitivity = settings["duplicate_sensitivity"]
|
| 881 |
+
enable_auto_resolve = settings["enable_auto_resolve"]
|
| 882 |
|
| 883 |
# Grab client metadata
|
| 884 |
client_ip = request.client.host if request.client else "unknown"
|
|
|
|
| 911 |
and duplicate check before committing to a ticket creation.
|
| 912 |
"""
|
| 913 |
text = request_body.text
|
|
|
|
|
|
|
| 914 |
print(f"[AI] Starting Analysis (READ-ONLY) for: {text[:50]}...")
|
| 915 |
settings = get_system_settings(request_body.company)
|
| 916 |
confidence_threshold = settings["ai_confidence_threshold"]
|
|
|
|
| 955 |
is_potential_duplicate=False,
|
| 956 |
parent_ticket_id=None,
|
| 957 |
sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 958 |
)
|
| 959 |
|
| 960 |
# --- Context & Environment ---
|
|
|
|
| 987 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 988 |
|
| 989 |
# --- Classification ---
|
| 990 |
+
try:
|
| 991 |
+
classification_v3_res = classifier_v3.predict(text)
|
| 992 |
+
if "error" in classification_v3_res:
|
| 993 |
+
# Fallback to V1
|
| 994 |
+
classification = classifier_service.predict(text)
|
| 995 |
+
else:
|
| 996 |
+
# Parse V3 output
|
| 997 |
+
cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
|
| 998 |
+
sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
|
| 999 |
+
pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
|
| 1000 |
+
conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
|
| 1001 |
+
|
| 1002 |
+
from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
|
| 1003 |
+
assigned_team = TEAM_MAP.get(cat, "General Support")
|
| 1004 |
+
auto_resolve = sub in AUTO_RESOLVE_SUBS
|
| 1005 |
+
|
| 1006 |
+
classification = {
|
| 1007 |
+
"category": cat,
|
| 1008 |
+
"subcategory": sub,
|
| 1009 |
+
"priority": pri,
|
| 1010 |
+
"auto_resolve": auto_resolve,
|
| 1011 |
+
"assigned_team": assigned_team,
|
| 1012 |
+
"confidence": float(conf)
|
| 1013 |
+
}
|
| 1014 |
+
except Exception as e:
|
| 1015 |
+
traceback.print_exc()
|
| 1016 |
+
classification = {
|
| 1017 |
+
"category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
|
| 1018 |
+
"auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
|
| 1019 |
+
}
|
| 1020 |
|
| 1021 |
timeline["ai_analyzed"] = get_now_ist()
|
| 1022 |
timeline["triaged"] = get_now_ist()
|
|
|
|
| 1030 |
timeline["metadata_harvested"] = get_now_ist()
|
| 1031 |
|
| 1032 |
# --- Duplicate detection ---
|
| 1033 |
+
duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
|
| 1034 |
try:
|
| 1035 |
+
dup_result = detect_semantic_duplicate(
|
| 1036 |
+
text,
|
| 1037 |
+
company_id=request_body.company_id,
|
| 1038 |
+
threshold=duplicate_threshold,
|
| 1039 |
+
)
|
| 1040 |
except Exception:
|
| 1041 |
+
dup_result = {
|
| 1042 |
+
"is_duplicate": False,
|
| 1043 |
+
"duplicate_ticket_id": None,
|
| 1044 |
+
"parent_ticket_id": None,
|
| 1045 |
+
"is_potential_duplicate": False,
|
| 1046 |
+
"similarity": 0.0,
|
| 1047 |
+
}
|
| 1048 |
|
| 1049 |
# --- RAG Knowledge Base Check ---
|
| 1050 |
rag_match = None
|
|
|
|
| 1060 |
|
| 1061 |
# --- Reasoning ---
|
| 1062 |
decision_factors = []
|
| 1063 |
+
if classification["confidence"] > confidence_threshold:
|
| 1064 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1065 |
if entities:
|
| 1066 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
|
|
| 1070 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1071 |
|
| 1072 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1073 |
+
if (
|
| 1074 |
+
enable_auto_resolve
|
| 1075 |
+
and classification["confidence"] >= confidence_threshold
|
| 1076 |
+
and classification["auto_resolve"]
|
| 1077 |
+
):
|
| 1078 |
+
classification["auto_resolve"] = True
|
| 1079 |
+
else:
|
| 1080 |
+
classification["auto_resolve"] = False
|
| 1081 |
if classification["auto_resolve"]:
|
| 1082 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
| 1083 |
|
|
|
|
| 1087 |
if gemini_service and gemini_service._initialized:
|
| 1088 |
summary = gemini_service.get_summary(text)
|
| 1089 |
|
| 1090 |
+
# Convert priority to the SLA resolution target timestamp for preview.
|
| 1091 |
+
sla_breach_dt = calculate_sla_breach_at(classification["priority"])
|
|
|
|
|
|
|
| 1092 |
|
| 1093 |
return TicketResponse(
|
| 1094 |
ticket_id=str(uuid.uuid4()), # Temporary ID
|
|
|
|
| 1101 |
entities=[EntityInfo(**e) for e in entities],
|
| 1102 |
duplicate_ticket=DuplicateInfo(**dup_result),
|
| 1103 |
confidence=classification["confidence"],
|
| 1104 |
+
needs_review=classification["confidence"] < confidence_threshold,
|
| 1105 |
reasoning=reasoning,
|
| 1106 |
decision_factors=decision_factors,
|
| 1107 |
image_description=gemini_analysis["image_description"],
|
| 1108 |
ocr_text=gemini_analysis["ocr_text"],
|
| 1109 |
+
highlights=entities, # Use entities as highlights for now
|
| 1110 |
timeline=timeline,
|
| 1111 |
env_metadata=env_metadata,
|
| 1112 |
is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
|
| 1113 |
parent_ticket_id=dup_result.get("parent_ticket_id"),
|
| 1114 |
+
sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1115 |
)
|
| 1116 |
|
| 1117 |
@app.post("/ai/analyze_stream")
|
|
|
|
| 1130 |
"model_version": "3.0.0-PRO",
|
| 1131 |
"api_endpoint": "/ai/analyze_stream"
|
| 1132 |
}
|
| 1133 |
+
timeline = {"received": get_now_ist()}
|
| 1134 |
+
settings = get_system_settings(request_body.company_id)
|
| 1135 |
+
confidence_threshold = settings["ai_confidence_threshold"]
|
| 1136 |
+
duplicate_sensitivity = settings["duplicate_sensitivity"]
|
| 1137 |
+
enable_auto_resolve = settings["enable_auto_resolve"]
|
| 1138 |
|
| 1139 |
# 1. Reading
|
| 1140 |
yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
|
|
|
|
| 1162 |
# 3. Classification
|
| 1163 |
yield f"data: {json.dumps({'step': 'Detecting category and priority', 'status': 'in_progress'})}\n\n"
|
| 1164 |
await asyncio.sleep(0.2)
|
| 1165 |
+
try:
|
| 1166 |
+
classification_v3_res = classifier_v3.predict(text)
|
| 1167 |
+
if "error" in classification_v3_res:
|
| 1168 |
+
classification = classifier_service.predict(text)
|
| 1169 |
+
else:
|
| 1170 |
+
cat = classification_v3_res.get("Category", {}).get("prediction", "Unknown")
|
| 1171 |
+
sub = classification_v3_res.get("Subcategory", {}).get("prediction", "Unknown")
|
| 1172 |
+
pri = classification_v3_res.get("priority", {}).get("prediction", "Medium")
|
| 1173 |
+
conf = classification_v3_res.get("Category", {}).get("confidence", 0.0)
|
| 1174 |
+
|
| 1175 |
+
from backend.services.classifier_service import TEAM_MAP, AUTO_RESOLVE_SUBS
|
| 1176 |
+
assigned_team = TEAM_MAP.get(cat, "General Support")
|
| 1177 |
+
auto_resolve = sub in AUTO_RESOLVE_SUBS
|
| 1178 |
+
|
| 1179 |
+
classification = {
|
| 1180 |
+
"category": cat,
|
| 1181 |
+
"subcategory": sub,
|
| 1182 |
+
"priority": pri,
|
| 1183 |
+
"auto_resolve": auto_resolve,
|
| 1184 |
+
"assigned_team": assigned_team,
|
| 1185 |
+
"confidence": float(conf)
|
| 1186 |
+
}
|
| 1187 |
+
except Exception as e:
|
| 1188 |
+
classification = {
|
| 1189 |
+
"category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
|
| 1190 |
+
"auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
|
| 1191 |
+
}
|
| 1192 |
timeline["ai_analyzed"] = get_now_ist()
|
| 1193 |
timeline["triaged"] = get_now_ist()
|
| 1194 |
|
|
|
|
| 1196 |
yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
|
| 1197 |
await asyncio.sleep(0.2)
|
| 1198 |
try:
|
| 1199 |
+
duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
|
| 1200 |
+
dup_result = detect_semantic_duplicate(
|
| 1201 |
+
text,
|
| 1202 |
+
company_id=request_body.company_id,
|
| 1203 |
+
threshold=duplicate_threshold,
|
| 1204 |
+
)
|
| 1205 |
except Exception:
|
| 1206 |
+
dup_result = {
|
| 1207 |
+
"is_duplicate": False,
|
| 1208 |
+
"duplicate_ticket_id": None,
|
| 1209 |
+
"parent_ticket_id": None,
|
| 1210 |
+
"is_potential_duplicate": False,
|
| 1211 |
+
"similarity": 0.0,
|
| 1212 |
+
}
|
| 1213 |
|
| 1214 |
# 5. RAG / Solutions
|
| 1215 |
yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
|
|
|
|
| 1225 |
pass
|
| 1226 |
|
| 1227 |
decision_factors = []
|
| 1228 |
+
if classification["confidence"] > confidence_threshold:
|
| 1229 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1230 |
if entities:
|
| 1231 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
|
|
| 1234 |
if rag_match:
|
| 1235 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1236 |
|
| 1237 |
+
if not enable_auto_resolve:
|
| 1238 |
+
classification["auto_resolve"] = False
|
| 1239 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1240 |
if classification["auto_resolve"]:
|
| 1241 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
|
|
|
| 1245 |
if gemini_service and gemini_service._initialized:
|
| 1246 |
summary = gemini_service.get_summary(text)
|
| 1247 |
|
| 1248 |
+
sla_breach_dt = calculate_sla_breach_at(classification["priority"])
|
|
|
|
|
|
|
| 1249 |
|
| 1250 |
ticket_response_dict = {
|
| 1251 |
"ticket_id": str(uuid.uuid4()),
|
|
|
|
| 1258 |
"entities": [e for e in entities],
|
| 1259 |
"duplicate_ticket": dup_result,
|
| 1260 |
"confidence": classification["confidence"],
|
| 1261 |
+
"needs_review": classification["confidence"] < confidence_threshold,
|
| 1262 |
"reasoning": reasoning,
|
| 1263 |
"decision_factors": decision_factors,
|
| 1264 |
"image_description": gemini_analysis["image_description"],
|
| 1265 |
"ocr_text": gemini_analysis["ocr_text"],
|
| 1266 |
+
"highlights": entities,
|
| 1267 |
"timeline": timeline,
|
| 1268 |
"env_metadata": env_metadata,
|
| 1269 |
+
"is_potential_duplicate": dup_result.get("is_potential_duplicate", False),
|
| 1270 |
+
"parent_ticket_id": dup_result.get("parent_ticket_id"),
|
| 1271 |
+
"sla_breach_at": sla_breach_dt.isoformat().replace("+00:00", "Z")
|
| 1272 |
}
|
| 1273 |
|
| 1274 |
# 6. Final Result
|
|
|
|
| 1300 |
}
|
| 1301 |
except Exception as e:
|
| 1302 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/requirements.txt
CHANGED
|
@@ -4,8 +4,6 @@ datasets>=2.14.0
|
|
| 4 |
pandas>=2.0.0
|
| 5 |
scikit-learn>=1.3.0
|
| 6 |
sentence-transformers>=2.2.0
|
| 7 |
-
onnxruntime>=1.17.0
|
| 8 |
-
tokenizers>=0.15.0
|
| 9 |
fastapi>=0.104.0
|
| 10 |
uvicorn>=0.24.0
|
| 11 |
openpyxl>=3.1.0
|
|
@@ -17,8 +15,4 @@ easyocr
|
|
| 17 |
slowapi>=0.1.9
|
| 18 |
supabase==2.22.4
|
| 19 |
storage3==2.22.4
|
| 20 |
-
|
| 21 |
-
pytest
|
| 22 |
-
pytest-asyncio
|
| 23 |
-
httpx
|
| 24 |
-
|
|
|
|
| 4 |
pandas>=2.0.0
|
| 5 |
scikit-learn>=1.3.0
|
| 6 |
sentence-transformers>=2.2.0
|
|
|
|
|
|
|
| 7 |
fastapi>=0.104.0
|
| 8 |
uvicorn>=0.24.0
|
| 9 |
openpyxl>=3.1.0
|
|
|
|
| 15 |
slowapi>=0.1.9
|
| 16 |
supabase==2.22.4
|
| 17 |
storage3==2.22.4
|
| 18 |
+
prometheus-client>=0.19.0
|
|
|
|
|
|
|
|
|
|
|
|
backend/services/classifier_service.py
CHANGED
|
@@ -6,20 +6,23 @@ Priority and other fields are derived from the category mapping.
|
|
| 6 |
|
| 7 |
import os
|
| 8 |
import json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
try:
|
| 10 |
-
import
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
DistilBertForSequenceClassification = None
|
| 19 |
-
_HAS_TORCH = False
|
| 20 |
|
| 21 |
SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "classifier")
|
| 22 |
-
DEVICE = torch.device("cuda" if torch
|
| 23 |
MAX_LEN = 128
|
| 24 |
|
| 25 |
# Priority mapping based on sub-category severity
|
|
@@ -65,11 +68,6 @@ class ClassifierService:
|
|
| 65 |
if self._loaded:
|
| 66 |
return
|
| 67 |
|
| 68 |
-
if not _HAS_TORCH:
|
| 69 |
-
# Degraded environment: ML runtime not available. Delay failure until predict is called.
|
| 70 |
-
print("[INFO] ML runtime not available; classifier will remain unloaded until dependencies are installed.")
|
| 71 |
-
return
|
| 72 |
-
|
| 73 |
abs_dir = os.path.abspath(SAVE_DIR)
|
| 74 |
|
| 75 |
if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
|
|
@@ -111,11 +109,21 @@ class ClassifierService:
|
|
| 111 |
input_ids = encoding["input_ids"].to(DEVICE)
|
| 112 |
attention_mask = encoding["attention_mask"].to(DEVICE)
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
pred_idx = pred_idx.item()
|
| 121 |
confidence = round(confidence.item(), 4)
|
|
|
|
| 6 |
|
| 7 |
import os
|
| 8 |
import json
|
| 9 |
+
import time
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification
|
| 13 |
+
|
| 14 |
try:
|
| 15 |
+
from backend.services.metrics_service import (
|
| 16 |
+
CLASSIFIER_LATENCY,
|
| 17 |
+
CLASSIFIER_REQUESTS,
|
| 18 |
+
CLASSIFIER_TOKENS,
|
| 19 |
+
)
|
| 20 |
+
_METRICS_ENABLED = True
|
| 21 |
+
except Exception:
|
| 22 |
+
_METRICS_ENABLED = False
|
|
|
|
|
|
|
| 23 |
|
| 24 |
SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models", "classifier")
|
| 25 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 26 |
MAX_LEN = 128
|
| 27 |
|
| 28 |
# Priority mapping based on sub-category severity
|
|
|
|
| 68 |
if self._loaded:
|
| 69 |
return
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
abs_dir = os.path.abspath(SAVE_DIR)
|
| 72 |
|
| 73 |
if not os.path.exists(os.path.join(abs_dir, "model.safetensors")):
|
|
|
|
| 109 |
input_ids = encoding["input_ids"].to(DEVICE)
|
| 110 |
attention_mask = encoding["attention_mask"].to(DEVICE)
|
| 111 |
|
| 112 |
+
_t0 = time.perf_counter()
|
| 113 |
+
try:
|
| 114 |
+
with torch.no_grad():
|
| 115 |
+
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
|
| 116 |
+
logits = outputs.logits
|
| 117 |
+
probs = F.softmax(logits, dim=1)
|
| 118 |
+
confidence, pred_idx = torch.max(probs, dim=1)
|
| 119 |
+
except Exception:
|
| 120 |
+
if _METRICS_ENABLED:
|
| 121 |
+
CLASSIFIER_REQUESTS.labels(model="distilbert", status="error").inc()
|
| 122 |
+
raise
|
| 123 |
+
if _METRICS_ENABLED:
|
| 124 |
+
CLASSIFIER_LATENCY.labels(model="distilbert").observe(time.perf_counter() - _t0)
|
| 125 |
+
CLASSIFIER_REQUESTS.labels(model="distilbert", status="ok").inc()
|
| 126 |
+
CLASSIFIER_TOKENS.labels(model="distilbert").inc(int(attention_mask.sum().item()))
|
| 127 |
|
| 128 |
pred_idx = pred_idx.item()
|
| 129 |
confidence = round(confidence.item(), 4)
|
backend/services/classifier_v2.py
CHANGED
|
@@ -1,109 +1,89 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import pickle
|
| 3 |
import json
|
| 4 |
-
|
| 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,7 +2,6 @@ import os
|
|
| 2 |
import base64
|
| 3 |
import io
|
| 4 |
import re
|
| 5 |
-
import json
|
| 6 |
from PIL import Image
|
| 7 |
from google import genai
|
| 8 |
from dotenv import load_dotenv
|
|
@@ -223,65 +222,3 @@ class GeminiService:
|
|
| 223 |
except Exception as e:
|
| 224 |
print(f"[GeminiService] Bug Analysis Error: {e}")
|
| 225 |
return f"Diagnostic analysis failed: {str(e)}"
|
| 226 |
-
|
| 227 |
-
def detect_language(self, text: str) -> dict:
|
| 228 |
-
"""
|
| 229 |
-
Detect language for the given text. Returns ISO-ish language code and English language name.
|
| 230 |
-
"""
|
| 231 |
-
if not text or not text.strip():
|
| 232 |
-
return {"code": "en", "name": "English"}
|
| 233 |
-
if not self._initialized:
|
| 234 |
-
return {"code": "en", "name": "English"}
|
| 235 |
-
|
| 236 |
-
try:
|
| 237 |
-
prompt = (
|
| 238 |
-
"Detect the natural language of the following user message. "
|
| 239 |
-
"Return strict JSON only with keys: code, name. "
|
| 240 |
-
"Example: {\"code\":\"es\",\"name\":\"Spanish\"}.\n\n"
|
| 241 |
-
f"Text:\n{text}"
|
| 242 |
-
)
|
| 243 |
-
response = self.client.models.generate_content(
|
| 244 |
-
model=self.model_name,
|
| 245 |
-
contents=prompt
|
| 246 |
-
)
|
| 247 |
-
raw = (response.text or "").strip()
|
| 248 |
-
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
| 249 |
-
parsed = json.loads(match.group(0) if match else raw)
|
| 250 |
-
code = str(parsed.get("code", "en")).lower()
|
| 251 |
-
name = str(parsed.get("name", "English"))
|
| 252 |
-
if not code:
|
| 253 |
-
code = "en"
|
| 254 |
-
if not name:
|
| 255 |
-
name = "English"
|
| 256 |
-
return {"code": code, "name": name}
|
| 257 |
-
except Exception as e:
|
| 258 |
-
print(f"[GeminiService] Language detection error: {e}")
|
| 259 |
-
return {"code": "en", "name": "English"}
|
| 260 |
-
|
| 261 |
-
def translate_to_english(self, text: str, source_language: str | None = None) -> str:
|
| 262 |
-
"""
|
| 263 |
-
Translate user text to English while preserving technical terms.
|
| 264 |
-
"""
|
| 265 |
-
if not text or not text.strip():
|
| 266 |
-
return text
|
| 267 |
-
if not self._initialized:
|
| 268 |
-
return text
|
| 269 |
-
|
| 270 |
-
try:
|
| 271 |
-
lang_hint = f"Source language: {source_language}. " if source_language else ""
|
| 272 |
-
prompt = (
|
| 273 |
-
"Translate the following support ticket text to natural, concise English. "
|
| 274 |
-
"Preserve technical terms, error codes, product names, and formatting. "
|
| 275 |
-
"Return only translated text with no prefix or explanation. "
|
| 276 |
-
f"{lang_hint}\n\n"
|
| 277 |
-
f"Text:\n{text}"
|
| 278 |
-
)
|
| 279 |
-
response = self.client.models.generate_content(
|
| 280 |
-
model=self.model_name,
|
| 281 |
-
contents=prompt
|
| 282 |
-
)
|
| 283 |
-
translated = (response.text or "").strip()
|
| 284 |
-
return translated or text
|
| 285 |
-
except Exception as e:
|
| 286 |
-
print(f"[GeminiService] Translation error: {e}")
|
| 287 |
-
return text
|
|
|
|
| 2 |
import base64
|
| 3 |
import io
|
| 4 |
import re
|
|
|
|
| 5 |
from PIL import Image
|
| 6 |
from google import genai
|
| 7 |
from dotenv import load_dotenv
|
|
|
|
| 222 |
except Exception as e:
|
| 223 |
print(f"[GeminiService] Bug Analysis Error: {e}")
|
| 224 |
return f"Diagnostic analysis failed: {str(e)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/services/metrics_service.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prometheus metrics for AI inference telemetry.
|
| 3 |
+
|
| 4 |
+
Exposes histograms and counters for DistilBERT classification latency,
|
| 5 |
+
total inference requests, and API token counts. Collected by Prometheus
|
| 6 |
+
via the FastAPI /metrics endpoint and visualized in Grafana.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from prometheus_client import Counter, Histogram
|
| 10 |
+
|
| 11 |
+
# DistilBERT classification latency (seconds)
|
| 12 |
+
CLASSIFIER_LATENCY = Histogram(
|
| 13 |
+
"ai_classifier_inference_latency_seconds",
|
| 14 |
+
"Latency of DistilBERT classifier inference in seconds",
|
| 15 |
+
labelnames=("model",),
|
| 16 |
+
buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# Total classifier inference requests, by model and status (ok|error)
|
| 20 |
+
CLASSIFIER_REQUESTS = Counter(
|
| 21 |
+
"ai_classifier_inference_requests_total",
|
| 22 |
+
"Total number of classifier inference requests",
|
| 23 |
+
labelnames=("model", "status"),
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Token counts processed by the classifier (input tokens)
|
| 27 |
+
CLASSIFIER_TOKENS = Counter(
|
| 28 |
+
"ai_classifier_input_tokens_total",
|
| 29 |
+
"Total number of input tokens processed by the classifier",
|
| 30 |
+
labelnames=("model",),
|
| 31 |
+
)
|
backend/services/ner_service.py
CHANGED
|
@@ -5,20 +5,12 @@ Labels follow pattern: B-B-ENTITY_TYPE, I-B-ENTITY_TYPE, O
|
|
| 5 |
|
| 6 |
import os
|
| 7 |
import json
|
| 8 |
-
|
| 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:
|
|
@@ -118,7 +114,7 @@ class SlaEscalationService:
|
|
| 118 |
self.now_fn = now_fn
|
| 119 |
self.notification_router = notification_router
|
| 120 |
|
| 121 |
-
def run_once(self
|
| 122 |
stats: dict[str, int | str] = {
|
| 123 |
"processed_count": 0,
|
| 124 |
"breached_count": 0,
|
|
@@ -140,7 +136,7 @@ class SlaEscalationService:
|
|
| 140 |
if not self._should_breach(ticket, now):
|
| 141 |
stats["skipped_count"] = int(stats["skipped_count"]) + 1
|
| 142 |
continue
|
| 143 |
-
self._breach_ticket(ticket, now
|
| 144 |
stats["breached_count"] = int(stats["breached_count"]) + 1
|
| 145 |
except Exception as exc:
|
| 146 |
stats["error_count"] = int(stats["error_count"]) + 1
|
|
@@ -173,13 +169,7 @@ class SlaEscalationService:
|
|
| 173 |
return False
|
| 174 |
return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
|
| 175 |
|
| 176 |
-
def _breach_ticket(
|
| 177 |
-
self,
|
| 178 |
-
ticket: dict[str, Any],
|
| 179 |
-
now: datetime,
|
| 180 |
-
*,
|
| 181 |
-
background_tasks: BackgroundTasks | None = None,
|
| 182 |
-
) -> None:
|
| 183 |
ticket_id = str(ticket.get("id"))
|
| 184 |
company_id = ticket.get("company_id")
|
| 185 |
escalation_level = int(ticket.get("escalation_level") or 0) + 1
|
|
@@ -195,7 +185,6 @@ class SlaEscalationService:
|
|
| 195 |
|
| 196 |
self._insert_audit_log(ticket, escalation_level, timestamp)
|
| 197 |
self._emit_system_message(ticket, escalation_level, timestamp)
|
| 198 |
-
self._dispatch_breach_alert(ticket, now, background_tasks=background_tasks)
|
| 199 |
|
| 200 |
logger.warning(
|
| 201 |
"SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
|
|
@@ -205,31 +194,6 @@ class SlaEscalationService:
|
|
| 205 |
escalation_level,
|
| 206 |
)
|
| 207 |
|
| 208 |
-
def _dispatch_breach_alert(
|
| 209 |
-
self,
|
| 210 |
-
ticket: dict[str, Any],
|
| 211 |
-
breach_time: datetime,
|
| 212 |
-
*,
|
| 213 |
-
background_tasks: BackgroundTasks | None = None,
|
| 214 |
-
) -> None:
|
| 215 |
-
ticket_id = str(ticket.get("id") or "")
|
| 216 |
-
subject = str(ticket.get("subject") or "Untitled ticket")
|
| 217 |
-
category = str(ticket.get("priority") or "Uncategorized")
|
| 218 |
-
assignee = str(ticket.get("assigned_team") or "Unassigned")
|
| 219 |
-
|
| 220 |
-
if background_tasks is not None:
|
| 221 |
-
background_tasks.add_task(
|
| 222 |
-
dispatch_slack_alert,
|
| 223 |
-
ticket_id,
|
| 224 |
-
subject,
|
| 225 |
-
category,
|
| 226 |
-
assignee,
|
| 227 |
-
breach_time,
|
| 228 |
-
)
|
| 229 |
-
return
|
| 230 |
-
|
| 231 |
-
dispatch_slack_alert(ticket_id, subject, category, assignee, breach_time)
|
| 232 |
-
|
| 233 |
def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
|
| 234 |
ticket_id = str(ticket.get("id"))
|
| 235 |
self.supabase.table("audit_logs").insert(
|
|
|
|
| 14 |
from datetime import datetime, timedelta, timezone
|
| 15 |
from typing import Any, Callable, Optional
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
try:
|
| 18 |
from dotenv import load_dotenv
|
| 19 |
except ImportError:
|
|
|
|
| 114 |
self.now_fn = now_fn
|
| 115 |
self.notification_router = notification_router
|
| 116 |
|
| 117 |
+
def run_once(self) -> dict[str, int | str]:
|
| 118 |
stats: dict[str, int | str] = {
|
| 119 |
"processed_count": 0,
|
| 120 |
"breached_count": 0,
|
|
|
|
| 136 |
if not self._should_breach(ticket, now):
|
| 137 |
stats["skipped_count"] = int(stats["skipped_count"]) + 1
|
| 138 |
continue
|
| 139 |
+
self._breach_ticket(ticket, now)
|
| 140 |
stats["breached_count"] = int(stats["breached_count"]) + 1
|
| 141 |
except Exception as exc:
|
| 142 |
stats["error_count"] = int(stats["error_count"]) + 1
|
|
|
|
| 169 |
return False
|
| 170 |
return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
|
| 171 |
|
| 172 |
+
def _breach_ticket(self, ticket: dict[str, Any], now: datetime) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
ticket_id = str(ticket.get("id"))
|
| 174 |
company_id = ticket.get("company_id")
|
| 175 |
escalation_level = int(ticket.get("escalation_level") or 0) + 1
|
|
|
|
| 185 |
|
| 186 |
self._insert_audit_log(ticket, escalation_level, timestamp)
|
| 187 |
self._emit_system_message(ticket, escalation_level, timestamp)
|
|
|
|
| 188 |
|
| 189 |
logger.warning(
|
| 190 |
"SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
|
|
|
|
| 194 |
escalation_level,
|
| 195 |
)
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
|
| 198 |
ticket_id = str(ticket.get("id"))
|
| 199 |
self.supabase.table("audit_logs").insert(
|
deploy/monitoring/grafana_dashboard.json
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"annotations": {
|
| 3 |
+
"list": [
|
| 4 |
+
{
|
| 5 |
+
"builtIn": 1,
|
| 6 |
+
"datasource": {
|
| 7 |
+
"type": "datasource",
|
| 8 |
+
"uid": "grafana"
|
| 9 |
+
},
|
| 10 |
+
"enable": true,
|
| 11 |
+
"hide": true,
|
| 12 |
+
"iconColor": "rgba(0, 211, 255, 1)",
|
| 13 |
+
"name": "Annotations & Alerts",
|
| 14 |
+
"type": "dashboard"
|
| 15 |
+
}
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
"description": "HELPDESK.AI service telemetry — AI inference latency, API throughput, token counts, and host resources.",
|
| 19 |
+
"editable": true,
|
| 20 |
+
"fiscalYearStartMonth": 0,
|
| 21 |
+
"graphTooltip": 0,
|
| 22 |
+
"id": null,
|
| 23 |
+
"links": [],
|
| 24 |
+
"liveNow": false,
|
| 25 |
+
"panels": [
|
| 26 |
+
{
|
| 27 |
+
"datasource": {
|
| 28 |
+
"type": "prometheus",
|
| 29 |
+
"uid": "${DS_PROMETHEUS}"
|
| 30 |
+
},
|
| 31 |
+
"fieldConfig": {
|
| 32 |
+
"defaults": {
|
| 33 |
+
"color": {"mode": "palette-classic"},
|
| 34 |
+
"custom": {
|
| 35 |
+
"axisLabel": "seconds",
|
| 36 |
+
"drawStyle": "line",
|
| 37 |
+
"fillOpacity": 10,
|
| 38 |
+
"lineWidth": 2,
|
| 39 |
+
"showPoints": "never"
|
| 40 |
+
},
|
| 41 |
+
"unit": "s"
|
| 42 |
+
},
|
| 43 |
+
"overrides": []
|
| 44 |
+
},
|
| 45 |
+
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},
|
| 46 |
+
"id": 1,
|
| 47 |
+
"options": {
|
| 48 |
+
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
|
| 49 |
+
"tooltip": {"mode": "multi"}
|
| 50 |
+
},
|
| 51 |
+
"title": "DistilBERT Inference Latency (p50 / p95 / p99)",
|
| 52 |
+
"targets": [
|
| 53 |
+
{
|
| 54 |
+
"expr": "histogram_quantile(0.50, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
|
| 55 |
+
"legendFormat": "p50",
|
| 56 |
+
"refId": "A"
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"expr": "histogram_quantile(0.95, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
|
| 60 |
+
"legendFormat": "p95",
|
| 61 |
+
"refId": "B"
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"expr": "histogram_quantile(0.99, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
|
| 65 |
+
"legendFormat": "p99",
|
| 66 |
+
"refId": "C"
|
| 67 |
+
}
|
| 68 |
+
],
|
| 69 |
+
"type": "timeseries"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"datasource": {
|
| 73 |
+
"type": "prometheus",
|
| 74 |
+
"uid": "${DS_PROMETHEUS}"
|
| 75 |
+
},
|
| 76 |
+
"fieldConfig": {
|
| 77 |
+
"defaults": {
|
| 78 |
+
"color": {"mode": "palette-classic"},
|
| 79 |
+
"custom": {
|
| 80 |
+
"axisLabel": "req/s",
|
| 81 |
+
"drawStyle": "line",
|
| 82 |
+
"fillOpacity": 10,
|
| 83 |
+
"lineWidth": 2,
|
| 84 |
+
"showPoints": "never"
|
| 85 |
+
},
|
| 86 |
+
"unit": "reqps"
|
| 87 |
+
},
|
| 88 |
+
"overrides": []
|
| 89 |
+
},
|
| 90 |
+
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},
|
| 91 |
+
"id": 2,
|
| 92 |
+
"options": {
|
| 93 |
+
"legend": {"calcs": ["mean"], "displayMode": "table", "placement": "bottom"},
|
| 94 |
+
"tooltip": {"mode": "multi"}
|
| 95 |
+
},
|
| 96 |
+
"title": "Classifier Request Rate (by status)",
|
| 97 |
+
"targets": [
|
| 98 |
+
{
|
| 99 |
+
"expr": "sum by (status) (rate(ai_classifier_inference_requests_total[1m]))",
|
| 100 |
+
"legendFormat": "{{status}}",
|
| 101 |
+
"refId": "A"
|
| 102 |
+
}
|
| 103 |
+
],
|
| 104 |
+
"type": "timeseries"
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"datasource": {
|
| 108 |
+
"type": "prometheus",
|
| 109 |
+
"uid": "${DS_PROMETHEUS}"
|
| 110 |
+
},
|
| 111 |
+
"fieldConfig": {
|
| 112 |
+
"defaults": {
|
| 113 |
+
"color": {"mode": "palette-classic"},
|
| 114 |
+
"custom": {
|
| 115 |
+
"axisLabel": "tokens/s",
|
| 116 |
+
"drawStyle": "line",
|
| 117 |
+
"fillOpacity": 10,
|
| 118 |
+
"lineWidth": 2,
|
| 119 |
+
"showPoints": "never"
|
| 120 |
+
},
|
| 121 |
+
"unit": "short"
|
| 122 |
+
},
|
| 123 |
+
"overrides": []
|
| 124 |
+
},
|
| 125 |
+
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 9},
|
| 126 |
+
"id": 3,
|
| 127 |
+
"options": {
|
| 128 |
+
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
|
| 129 |
+
"tooltip": {"mode": "multi"}
|
| 130 |
+
},
|
| 131 |
+
"title": "Token Throughput (input tokens / sec)",
|
| 132 |
+
"targets": [
|
| 133 |
+
{
|
| 134 |
+
"expr": "sum by (model) (rate(ai_classifier_input_tokens_total[1m]))",
|
| 135 |
+
"legendFormat": "{{model}}",
|
| 136 |
+
"refId": "A"
|
| 137 |
+
}
|
| 138 |
+
],
|
| 139 |
+
"type": "timeseries"
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"datasource": {
|
| 143 |
+
"type": "prometheus",
|
| 144 |
+
"uid": "${DS_PROMETHEUS}"
|
| 145 |
+
},
|
| 146 |
+
"fieldConfig": {
|
| 147 |
+
"defaults": {
|
| 148 |
+
"color": {"mode": "palette-classic"},
|
| 149 |
+
"custom": {
|
| 150 |
+
"axisLabel": "bytes",
|
| 151 |
+
"drawStyle": "line",
|
| 152 |
+
"fillOpacity": 10,
|
| 153 |
+
"lineWidth": 2,
|
| 154 |
+
"showPoints": "never"
|
| 155 |
+
},
|
| 156 |
+
"unit": "decbytes"
|
| 157 |
+
},
|
| 158 |
+
"overrides": []
|
| 159 |
+
},
|
| 160 |
+
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 9},
|
| 161 |
+
"id": 4,
|
| 162 |
+
"options": {
|
| 163 |
+
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
|
| 164 |
+
"tooltip": {"mode": "multi"}
|
| 165 |
+
},
|
| 166 |
+
"title": "Process Memory (RSS)",
|
| 167 |
+
"targets": [
|
| 168 |
+
{
|
| 169 |
+
"expr": "process_resident_memory_bytes",
|
| 170 |
+
"legendFormat": "{{instance}}",
|
| 171 |
+
"refId": "A"
|
| 172 |
+
}
|
| 173 |
+
],
|
| 174 |
+
"type": "timeseries"
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"datasource": {
|
| 178 |
+
"type": "prometheus",
|
| 179 |
+
"uid": "${DS_PROMETHEUS}"
|
| 180 |
+
},
|
| 181 |
+
"fieldConfig": {
|
| 182 |
+
"defaults": {
|
| 183 |
+
"color": {"mode": "palette-classic"},
|
| 184 |
+
"custom": {
|
| 185 |
+
"axisLabel": "CPU seconds/sec",
|
| 186 |
+
"drawStyle": "line",
|
| 187 |
+
"fillOpacity": 10,
|
| 188 |
+
"lineWidth": 2,
|
| 189 |
+
"showPoints": "never"
|
| 190 |
+
},
|
| 191 |
+
"unit": "percentunit"
|
| 192 |
+
},
|
| 193 |
+
"overrides": []
|
| 194 |
+
},
|
| 195 |
+
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 18},
|
| 196 |
+
"id": 5,
|
| 197 |
+
"options": {
|
| 198 |
+
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
|
| 199 |
+
"tooltip": {"mode": "multi"}
|
| 200 |
+
},
|
| 201 |
+
"title": "Process CPU Usage",
|
| 202 |
+
"targets": [
|
| 203 |
+
{
|
| 204 |
+
"expr": "rate(process_cpu_seconds_total[1m])",
|
| 205 |
+
"legendFormat": "{{instance}}",
|
| 206 |
+
"refId": "A"
|
| 207 |
+
}
|
| 208 |
+
],
|
| 209 |
+
"type": "timeseries"
|
| 210 |
+
}
|
| 211 |
+
],
|
| 212 |
+
"refresh": "10s",
|
| 213 |
+
"schemaVersion": 38,
|
| 214 |
+
"style": "dark",
|
| 215 |
+
"tags": ["helpdesk-ai", "fastapi", "ai-inference", "prometheus"],
|
| 216 |
+
"templating": {
|
| 217 |
+
"list": [
|
| 218 |
+
{
|
| 219 |
+
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
|
| 220 |
+
"hide": 0,
|
| 221 |
+
"includeAll": false,
|
| 222 |
+
"label": "Datasource",
|
| 223 |
+
"multi": false,
|
| 224 |
+
"name": "DS_PROMETHEUS",
|
| 225 |
+
"options": [],
|
| 226 |
+
"query": "prometheus",
|
| 227 |
+
"refresh": 1,
|
| 228 |
+
"regex": "",
|
| 229 |
+
"skipUrlSync": false,
|
| 230 |
+
"type": "datasource"
|
| 231 |
+
}
|
| 232 |
+
]
|
| 233 |
+
},
|
| 234 |
+
"time": {"from": "now-1h", "to": "now"},
|
| 235 |
+
"timepicker": {},
|
| 236 |
+
"timezone": "",
|
| 237 |
+
"title": "HELPDESK.AI — Service Telemetry",
|
| 238 |
+
"uid": "helpdesk-ai-telemetry",
|
| 239 |
+
"version": 1,
|
| 240 |
+
"weekStart": ""
|
| 241 |
+
}
|
requirements.txt
CHANGED
|
@@ -4,8 +4,6 @@ datasets>=2.14.0
|
|
| 4 |
pandas>=2.0.0
|
| 5 |
scikit-learn>=1.3.0
|
| 6 |
sentence-transformers>=2.2.0
|
| 7 |
-
onnxruntime>=1.17.0
|
| 8 |
-
tokenizers>=0.15.0
|
| 9 |
fastapi>=0.104.0
|
| 10 |
uvicorn>=0.24.0
|
| 11 |
openpyxl>=3.1.0
|
|
@@ -17,8 +15,4 @@ easyocr
|
|
| 17 |
slowapi>=0.1.9
|
| 18 |
supabase==2.22.4
|
| 19 |
storage3==2.22.4
|
| 20 |
-
|
| 21 |
-
pytest
|
| 22 |
-
pytest-asyncio
|
| 23 |
-
httpx
|
| 24 |
-
|
|
|
|
| 4 |
pandas>=2.0.0
|
| 5 |
scikit-learn>=1.3.0
|
| 6 |
sentence-transformers>=2.2.0
|
|
|
|
|
|
|
| 7 |
fastapi>=0.104.0
|
| 8 |
uvicorn>=0.24.0
|
| 9 |
openpyxl>=3.1.0
|
|
|
|
| 15 |
slowapi>=0.1.9
|
| 16 |
supabase==2.22.4
|
| 17 |
storage3==2.22.4
|
| 18 |
+
prometheus-client>=0.19.0
|
|
|
|
|
|
|
|
|
|
|
|
supabase/functions/send-user-approval-email/index.ts
CHANGED
|
@@ -1,144 +1,69 @@
|
|
| 1 |
-
import { serve } from
|
| 2 |
|
| 3 |
const corsHeaders = {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
"authorization, x-client-info, apikey, content-type",
|
| 8 |
};
|
| 9 |
|
| 10 |
-
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY") || "";
|
| 11 |
-
const FROM_EMAIL =
|
| 12 |
-
Deno.env.get("APPROVAL_FROM_EMAIL") ||
|
| 13 |
-
Deno.env.get("RESEND_FROM_EMAIL") ||
|
| 14 |
-
"HELPDESK.AI <bonthalamadhavi1@gmail.com>";
|
| 15 |
-
const DASHBOARD_URL =
|
| 16 |
-
Deno.env.get("FRONTEND_DASHBOARD_URL") ||
|
| 17 |
-
Deno.env.get("FRONTEND_URL") ||
|
| 18 |
-
"https://helpdeskaiv1.vercel.app/dashboard";
|
| 19 |
-
|
| 20 |
-
function buildApprovalHtml(name: string, company: string): string {
|
| 21 |
-
const safeName = name || "there";
|
| 22 |
-
const safeCompany = company || "your organization";
|
| 23 |
-
return `<!DOCTYPE html>
|
| 24 |
-
<html>
|
| 25 |
-
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
| 26 |
-
<body style="margin:0;padding:0;background-color:#f1f5f9;font-family:sans-serif;">
|
| 27 |
-
<table width="100%" cellspacing="0" cellpadding="0" style="background-color:#f1f5f9;padding:40px 20px;">
|
| 28 |
-
<tr><td align="center">
|
| 29 |
-
<table width="100%" style="max-width:600px;background:#ffffff;border-radius:24px;overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,0.05);">
|
| 30 |
-
<tr>
|
| 31 |
-
<td style="background:linear-gradient(135deg,#059669 0%,#047857 100%);padding:40px;text-align:center;">
|
| 32 |
-
<h1 style="color:#ffffff;margin:0;font-size:28px;font-weight:900;">HELPDESK<span style="color:#a7f3d0;">.AI</span></h1>
|
| 33 |
-
<p style="color:#d1fae5;margin:8px 0 0;font-size:14px;">Account Approved</p>
|
| 34 |
-
</td>
|
| 35 |
-
</tr>
|
| 36 |
-
<tr>
|
| 37 |
-
<td style="padding:40px;">
|
| 38 |
-
<h2 style="color:#0f172a;font-size:24px;margin:0 0 16px;">Hello ${safeName},</h2>
|
| 39 |
-
<p style="color:#64748b;font-size:16px;line-height:1.7;margin:0 0 24px;">
|
| 40 |
-
Your account for <strong>${safeCompany}</strong> has been approved by your administrator.
|
| 41 |
-
You can now sign in and access your dashboard.
|
| 42 |
-
</p>
|
| 43 |
-
<div align="center">
|
| 44 |
-
<a href="${DASHBOARD_URL}" style="display:inline-block;background-color:#059669;color:#ffffff;padding:16px 32px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:800;">
|
| 45 |
-
Go to Dashboard →
|
| 46 |
-
</a>
|
| 47 |
-
</div>
|
| 48 |
-
</td>
|
| 49 |
-
</tr>
|
| 50 |
-
<tr>
|
| 51 |
-
<td style="background-color:#f8fafc;padding:24px;text-align:center;border-top:1px solid #f1f5f9;">
|
| 52 |
-
<p style="margin:0;color:#94a3b8;font-size:12px;">© 2026 HELPDESK.AI</p>
|
| 53 |
-
</td>
|
| 54 |
-
</tr>
|
| 55 |
-
</table>
|
| 56 |
-
</td></tr>
|
| 57 |
-
</table>
|
| 58 |
-
</body>
|
| 59 |
-
</html>`;
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
serve(async (req) => {
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
try {
|
| 68 |
-
const { userId, email, name, company } = await req.json();
|
| 69 |
-
|
| 70 |
-
if (!email) {
|
| 71 |
-
throw new Error("Email is required");
|
| 72 |
}
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
`[send-user-approval-email] RESEND_API_KEY missing — cannot send to ${email}`,
|
| 77 |
-
);
|
| 78 |
-
return new Response(
|
| 79 |
-
JSON.stringify({
|
| 80 |
-
success: false,
|
| 81 |
-
error: "RESEND_API_KEY is not configured",
|
| 82 |
-
userId: userId ?? null,
|
| 83 |
-
}),
|
| 84 |
-
{
|
| 85 |
-
status: 503,
|
| 86 |
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
| 87 |
-
},
|
| 88 |
-
);
|
| 89 |
-
}
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
"Content-Type": "application/json",
|
| 99 |
-
Authorization: `Bearer ${RESEND_API_KEY}`,
|
| 100 |
-
},
|
| 101 |
-
body: JSON.stringify({
|
| 102 |
-
from: FROM_EMAIL,
|
| 103 |
-
to: [email],
|
| 104 |
-
subject: "Account Approved — Welcome to HELPDESK.AI",
|
| 105 |
-
html: buildApprovalHtml(name, company),
|
| 106 |
-
}),
|
| 107 |
-
});
|
| 108 |
|
| 109 |
-
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
)
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
| 144 |
});
|
|
|
|
| 1 |
+
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
| 2 |
|
| 3 |
const corsHeaders = {
|
| 4 |
+
'Access-Control-Allow-Origin': '*',
|
| 5 |
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
| 6 |
+
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
|
|
| 7 |
};
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
serve(async (req) => {
|
| 10 |
+
// Handle CORS preflight requests
|
| 11 |
+
if (req.method === 'OPTIONS') {
|
| 12 |
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
+
try {
|
| 16 |
+
const { userId, email, name, company } = await req.json();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
if (!email) {
|
| 19 |
+
throw new Error('Email is required');
|
| 20 |
+
}
|
| 21 |
|
| 22 |
+
// Initialize Resend or another mail provider (Assuming user configures this later)
|
| 23 |
+
// const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
|
| 24 |
+
// Currently simulating a successfully sent email.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
console.log(`Sending approval email to ${email} for user ${name} in company ${company}...`);
|
| 27 |
|
| 28 |
+
// The email content could be sent via Resend:
|
| 29 |
+
/*
|
| 30 |
+
const res = await fetch('https://api.resend.com/emails', {
|
| 31 |
+
method: 'POST',
|
| 32 |
+
headers: {
|
| 33 |
+
'Content-Type': 'application/json',
|
| 34 |
+
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
| 35 |
+
},
|
| 36 |
+
body: JSON.stringify({
|
| 37 |
+
from: 'HelpDesk.ai <noreply@yourdomain.com>',
|
| 38 |
+
to: [email],
|
| 39 |
+
subject: 'Account Approved! Welcome to HelpDesk.ai',
|
| 40 |
+
html: `
|
| 41 |
+
<h2>Hello ${name},</h2>
|
| 42 |
+
<p>Your account for <strong>${company}</strong> has been approved by your administrator!</p>
|
| 43 |
+
<p>You can now log in to the system and access your dashboard.</p>
|
| 44 |
+
<a href="https://your-frontend-url.com/dashboard" style="background-color: #059669; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Go to Dashboard</a>
|
| 45 |
+
`
|
| 46 |
+
})
|
| 47 |
+
});
|
| 48 |
+
*/
|
| 49 |
|
| 50 |
+
return new Response(
|
| 51 |
+
JSON.stringify({
|
| 52 |
+
success: true,
|
| 53 |
+
message: `Approval email simulated for ${email}`
|
| 54 |
+
}),
|
| 55 |
+
{
|
| 56 |
+
status: 200,
|
| 57 |
+
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
| 58 |
+
}
|
| 59 |
+
);
|
| 60 |
+
} catch (error) {
|
| 61 |
+
return new Response(
|
| 62 |
+
JSON.stringify({ error: error.message }),
|
| 63 |
+
{
|
| 64 |
+
status: 400,
|
| 65 |
+
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
| 66 |
+
}
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
});
|
supabase/migrations/20260522080000_add_sla_escalation.sql
CHANGED
|
@@ -43,7 +43,7 @@ CREATE POLICY "Service role full audit access" ON audit_logs
|
|
| 43 |
CREATE POLICY "Admins can view own company audit logs" ON audit_logs
|
| 44 |
FOR SELECT USING (
|
| 45 |
company_id IN (
|
| 46 |
-
SELECT company_id FROM
|
| 47 |
)
|
| 48 |
);
|
| 49 |
|
|
|
|
| 43 |
CREATE POLICY "Admins can view own company audit logs" ON audit_logs
|
| 44 |
FOR SELECT USING (
|
| 45 |
company_id IN (
|
| 46 |
+
SELECT company_id FROM user_companies WHERE user_id = auth.uid()
|
| 47 |
)
|
| 48 |
);
|
| 49 |
|
supabase/migrations/20260531_add_company_settings.sql
CHANGED
|
@@ -17,23 +17,6 @@ CREATE TABLE system_settings (
|
|
| 17 |
admin_alerts BOOLEAN NOT NULL DEFAULT TRUE,
|
| 18 |
digest_frequency TEXT NOT NULL DEFAULT 'daily' CHECK (digest_frequency IN ('daily', 'weekly')),
|
| 19 |
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 20 |
-
-- Create system_settings table for storing per-company system configuration
|
| 21 |
-
|
| 22 |
-
-- NOTE: migration filename contains 'add_company_settings'. The migration now creates
|
| 23 |
-
-- `system_settings` per the unified schema; filename unchanged for migration ordering.
|
| 24 |
-
|
| 25 |
-
CREATE TABLE IF NOT EXISTS system_settings (
|
| 26 |
-
company_id UUID PRIMARY KEY,
|
| 27 |
-
ai_confidence_threshold FLOAT DEFAULT 0.80,
|
| 28 |
-
duplicate_sensitivity FLOAT DEFAULT 0.85,
|
| 29 |
-
enable_auto_resolve BOOLEAN DEFAULT FALSE,
|
| 30 |
-
auto_close_enabled BOOLEAN DEFAULT TRUE,
|
| 31 |
-
auto_close_days INTEGER DEFAULT 7,
|
| 32 |
-
email_notifications BOOLEAN DEFAULT TRUE,
|
| 33 |
-
admin_alerts BOOLEAN DEFAULT TRUE,
|
| 34 |
-
digest_frequency TEXT DEFAULT 'daily',
|
| 35 |
-
created_at TIMESTAMPTZ DEFAULT NOW(),
|
| 36 |
-
updated_at TIMESTAMPTZ DEFAULT NOW()
|
| 37 |
);
|
| 38 |
|
| 39 |
-- Enable Row Level Security
|
|
@@ -54,49 +37,6 @@ CREATE POLICY "Company members can manage own settings" ON system_settings
|
|
| 54 |
)
|
| 55 |
);
|
| 56 |
|
| 57 |
-
CREATE POLICY "Admins can view own company settings" ON system_settings
|
| 58 |
-
FOR SELECT USING (
|
| 59 |
-
company_id IN (
|
| 60 |
-
SELECT company_id FROM profiles
|
| 61 |
-
WHERE id = auth.uid()
|
| 62 |
-
AND role IN ('admin', 'super_admin', 'master_admin')
|
| 63 |
-
)
|
| 64 |
-
);
|
| 65 |
-
|
| 66 |
-
-- Admins can create and update settings for their own company from the dashboard.
|
| 67 |
-
CREATE POLICY "Admins can insert own company settings" ON system_settings
|
| 68 |
-
FOR INSERT WITH CHECK (
|
| 69 |
-
company_id IN (
|
| 70 |
-
SELECT company_id FROM profiles
|
| 71 |
-
WHERE id = auth.uid()
|
| 72 |
-
AND role IN ('admin', 'super_admin', 'master_admin')
|
| 73 |
-
)
|
| 74 |
-
);
|
| 75 |
-
|
| 76 |
-
CREATE POLICY "Admins can update own company settings" ON system_settings
|
| 77 |
-
FOR UPDATE USING (
|
| 78 |
-
company_id IN (
|
| 79 |
-
SELECT company_id FROM profiles
|
| 80 |
-
WHERE id = auth.uid()
|
| 81 |
-
AND role IN ('admin', 'super_admin', 'master_admin')
|
| 82 |
-
)
|
| 83 |
-
)
|
| 84 |
-
WITH CHECK (
|
| 85 |
-
company_id IN (
|
| 86 |
-
SELECT company_id FROM profiles
|
| 87 |
-
WHERE id = auth.uid()
|
| 88 |
-
AND role IN ('admin', 'super_admin', 'master_admin')
|
| 89 |
-
)
|
| 90 |
-
);
|
| 91 |
-
|
| 92 |
-
CREATE OR REPLACE FUNCTION update_timestamp()
|
| 93 |
-
RETURNS TRIGGER AS $$
|
| 94 |
-
BEGIN
|
| 95 |
-
NEW.updated_at = NOW();
|
| 96 |
-
RETURN NEW;
|
| 97 |
-
END;
|
| 98 |
-
$$ LANGUAGE plpgsql;
|
| 99 |
-
|
| 100 |
-- Trigger to auto-update updated_at on modification
|
| 101 |
CREATE OR REPLACE FUNCTION update_system_settings_timestamp()
|
| 102 |
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|
|
|
| 17 |
admin_alerts BOOLEAN NOT NULL DEFAULT TRUE,
|
| 18 |
digest_frequency TEXT NOT NULL DEFAULT 'daily' CHECK (digest_frequency IN ('daily', 'weekly')),
|
| 19 |
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
);
|
| 21 |
|
| 22 |
-- Enable Row Level Security
|
|
|
|
| 37 |
)
|
| 38 |
);
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
-- Trigger to auto-update updated_at on modification
|
| 41 |
CREATE OR REPLACE FUNCTION update_system_settings_timestamp()
|
| 42 |
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|