Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Frontend/src/App.jsx +3 -9
- Frontend/src/admin/components/AdminSidebar.jsx +3 -1
- Frontend/src/admin/components/SLABadge.jsx +14 -25
- Frontend/src/admin/components/SLADashboard.jsx +376 -0
- Frontend/src/admin/pages/SLAPage.jsx +538 -0
- Frontend/src/components/shared/DuplicateWarningBanner.jsx +256 -0
- backend/main.py +350 -197
- backend/services/semantic_duplicate_service.py +260 -0
- backend/services/sla_engine.py +534 -0
- backend/sla_checker.py +162 -0
- supabase/functions/sla-notifier/index.ts +343 -0
- supabase/migrations/20260401000000_sla_engine.sql +204 -0
- supabase/migrations/20260402000000_semantic_duplicates.sql +140 -0
Frontend/src/App.jsx
CHANGED
|
@@ -54,6 +54,7 @@ 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
|
|
@@ -78,7 +79,6 @@ import MasterAdminDashboard from "./master-admin/pages/MasterAdminDashboard";
|
|
| 78 |
import PendingAdminRequests from "./master-admin/pages/PendingAdminRequests";
|
| 79 |
import AllCompanies from "./master-admin/pages/AllCompanies";
|
| 80 |
import AllAdmins from "./master-admin/pages/AllAdmins";
|
| 81 |
-
import Changelog from "./pages/Changelog";
|
| 82 |
|
| 83 |
|
| 84 |
function TitleUpdater() {
|
|
@@ -96,6 +96,7 @@ function TitleUpdater() {
|
|
| 96 |
else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
|
| 97 |
else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
|
| 98 |
else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
|
|
|
|
| 99 |
// Master Admin Routes
|
| 100 |
else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
|
| 101 |
else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
|
|
@@ -191,6 +192,7 @@ function AppLayout() {
|
|
| 191 |
<Route path="/admin/analytics" element={<AdminAnalytics />} />
|
| 192 |
<Route path="/admin/profile" element={<AdminProfile />} />
|
| 193 |
<Route path="/admin/settings" element={<AdminSettings />} />
|
|
|
|
| 194 |
</Route>
|
| 195 |
</Route>
|
| 196 |
|
|
@@ -206,11 +208,6 @@ function App() {
|
|
| 206 |
|
| 207 |
useEffect(() => {
|
| 208 |
initialize();
|
| 209 |
-
// Dark mode initialize
|
| 210 |
-
const saved = localStorage.getItem('theme');
|
| 211 |
-
if (saved === 'dark') {
|
| 212 |
-
document.documentElement.classList.add('dark');
|
| 213 |
-
}
|
| 214 |
}, [initialize]);
|
| 215 |
|
| 216 |
return (
|
|
@@ -237,9 +234,6 @@ function App() {
|
|
| 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 />} />
|
|
|
|
| 54 |
import AdminAnalytics from "./admin/pages/AdminAnalytics";
|
| 55 |
import AdminProfile from "./admin/pages/AdminProfile";
|
| 56 |
import AdminSettings from "./admin/pages/AdminSettings";
|
| 57 |
+
import SLAPage from "./admin/pages/SLAPage";
|
| 58 |
import MasterBugReports from "./master-admin/pages/MasterBugReports";
|
| 59 |
|
| 60 |
// Feature Pages
|
|
|
|
| 79 |
import PendingAdminRequests from "./master-admin/pages/PendingAdminRequests";
|
| 80 |
import AllCompanies from "./master-admin/pages/AllCompanies";
|
| 81 |
import AllAdmins from "./master-admin/pages/AllAdmins";
|
|
|
|
| 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 |
+
else if (path.startsWith('/admin/sla')) title = 'SLA Monitor | Admin';
|
| 100 |
// Master Admin Routes
|
| 101 |
else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
|
| 102 |
else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
|
|
|
|
| 192 |
<Route path="/admin/analytics" element={<AdminAnalytics />} />
|
| 193 |
<Route path="/admin/profile" element={<AdminProfile />} />
|
| 194 |
<Route path="/admin/settings" element={<AdminSettings />} />
|
| 195 |
+
<Route path="/admin/sla" element={<SLAPage />} />
|
| 196 |
</Route>
|
| 197 |
</Route>
|
| 198 |
|
|
|
|
| 208 |
|
| 209 |
useEffect(() => {
|
| 210 |
initialize();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
}, [initialize]);
|
| 212 |
|
| 213 |
return (
|
|
|
|
| 234 |
<Route path="/features/priority" element={<PriorityDetectionFeature />} />
|
| 235 |
<Route path="/features/resolution" element={<SmartResolutionFeature />} />
|
| 236 |
|
|
|
|
|
|
|
|
|
|
| 237 |
{/* Legal Pages */}
|
| 238 |
<Route path="/terms" element={<TermsOfService />} />
|
| 239 |
<Route path="/privacy" element={<PrivacyPolicy />} />
|
Frontend/src/admin/components/AdminSidebar.jsx
CHANGED
|
@@ -10,7 +10,8 @@ import {
|
|
| 10 |
LogOut,
|
| 11 |
Activity,
|
| 12 |
ChevronLeft,
|
| 13 |
-
ChevronRight
|
|
|
|
| 14 |
} from 'lucide-react';
|
| 15 |
import useAuthStore from '../../store/authStore';
|
| 16 |
|
|
@@ -21,6 +22,7 @@ const AdminSidebar = ({ isMobile, onClose, isCollapsed, onToggleCollapse }) => {
|
|
| 21 |
{ label: 'Users', path: '/admin/users', icon: Users },
|
| 22 |
{ label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
| 23 |
{ label: 'Profile', path: '/admin/profile', icon: UserCircle },
|
|
|
|
| 24 |
];
|
| 25 |
|
| 26 |
const { logout } = useAuthStore();
|
|
|
|
| 10 |
LogOut,
|
| 11 |
Activity,
|
| 12 |
ChevronLeft,
|
| 13 |
+
ChevronRight,
|
| 14 |
+
Clock
|
| 15 |
} from 'lucide-react';
|
| 16 |
import useAuthStore from '../../store/authStore';
|
| 17 |
|
|
|
|
| 22 |
{ label: 'Users', path: '/admin/users', icon: Users },
|
| 23 |
{ label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
| 24 |
{ label: 'Profile', path: '/admin/profile', icon: UserCircle },
|
| 25 |
+
{ label: 'SLA Monitor', path: '/admin/sla', icon: Clock },
|
| 26 |
];
|
| 27 |
|
| 28 |
const { logout } = useAuthStore();
|
Frontend/src/admin/components/SLABadge.jsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { Clock, AlertTriangle, ShieldCheck } from 'lucide-react';
|
| 3 |
|
| 4 |
-
// SLA
|
| 5 |
const SLA_LIMITS = {
|
| 6 |
-
critical:
|
| 7 |
-
high:
|
| 8 |
-
medium:
|
| 9 |
-
low:
|
| 10 |
};
|
| 11 |
|
| 12 |
function formatDuration(ms) {
|
|
@@ -19,47 +19,36 @@ function formatDuration(ms) {
|
|
| 19 |
}
|
| 20 |
|
| 21 |
/**
|
| 22 |
-
* SLABadge — shows SLA status for a ticket.
|
| 23 |
*
|
| 24 |
* Props:
|
| 25 |
* - priority: string ('critical' | 'high' | 'medium' | 'low')
|
| 26 |
* - createdAt: string (ISO date string)
|
| 27 |
-
* - slaBreachAt: string (ISO date string) — preferred persisted deadline
|
| 28 |
-
* - slaStatus: string ('ACTIVE' | 'WARNING' | 'BREACHED')
|
| 29 |
* - status: string — if ticket is resolved/closed, show "Met" without countdown
|
| 30 |
* - compact: bool — if true, shows just the badge with no label text
|
| 31 |
*/
|
| 32 |
-
export default function SLABadge({ priority, createdAt,
|
| 33 |
const [remaining, setRemaining] = useState(null);
|
| 34 |
|
| 35 |
-
const
|
| 36 |
-
const normalizedSlaStatus = slaStatus?.toUpperCase();
|
| 37 |
-
const isResolved = ['resolved', 'closed', 'auto-resolved', 'auto resolved'].includes(normalizedStatus);
|
| 38 |
|
| 39 |
useEffect(() => {
|
| 40 |
-
if (isResolved) return;
|
| 41 |
-
if (normalizedSlaStatus === 'BREACHED') {
|
| 42 |
-
setRemaining(-1);
|
| 43 |
-
return;
|
| 44 |
-
}
|
| 45 |
|
| 46 |
-
const priorityKey = priority
|
| 47 |
const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
|
| 48 |
-
const
|
| 49 |
-
? new Date(slaBreachAt).getTime()
|
| 50 |
-
: new Date(createdAt).getTime() + limit;
|
| 51 |
-
|
| 52 |
-
if (!Number.isFinite(deadlineMs)) return;
|
| 53 |
|
| 54 |
const calculate = () => {
|
| 55 |
-
const
|
|
|
|
| 56 |
setRemaining(rem);
|
| 57 |
};
|
| 58 |
|
| 59 |
calculate();
|
| 60 |
const timer = setInterval(calculate, 60 * 1000); // update every minute
|
| 61 |
return () => clearInterval(timer);
|
| 62 |
-
}, [priority, createdAt,
|
| 63 |
|
| 64 |
if (isResolved) {
|
| 65 |
return (
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { Clock, AlertTriangle, ShieldCheck } from 'lucide-react';
|
| 3 |
|
| 4 |
+
// SLA time limits in milliseconds based on priority
|
| 5 |
const SLA_LIMITS = {
|
| 6 |
+
critical: 2 * 60 * 60 * 1000, // 2 hours
|
| 7 |
+
high: 4 * 60 * 60 * 1000, // 4 hours
|
| 8 |
+
medium: 8 * 60 * 60 * 1000, // 8 hours
|
| 9 |
+
low: 24 * 60 * 60 * 1000, // 24 hours
|
| 10 |
};
|
| 11 |
|
| 12 |
function formatDuration(ms) {
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
/**
|
| 22 |
+
* SLABadge — shows SLA status for a ticket based on its priority and creation time.
|
| 23 |
*
|
| 24 |
* Props:
|
| 25 |
* - priority: string ('critical' | 'high' | 'medium' | 'low')
|
| 26 |
* - createdAt: string (ISO date string)
|
|
|
|
|
|
|
| 27 |
* - status: string — if ticket is resolved/closed, show "Met" without countdown
|
| 28 |
* - compact: bool — if true, shows just the badge with no label text
|
| 29 |
*/
|
| 30 |
+
export default function SLABadge({ priority, createdAt, status, compact = false }) {
|
| 31 |
const [remaining, setRemaining] = useState(null);
|
| 32 |
|
| 33 |
+
const isResolved = ['resolved', 'closed', 'auto-resolved'].includes(status?.toLowerCase());
|
|
|
|
|
|
|
| 34 |
|
| 35 |
useEffect(() => {
|
| 36 |
+
if (isResolved || !priority || !createdAt) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
const priorityKey = priority.toLowerCase();
|
| 39 |
const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
|
| 40 |
+
const createdMs = new Date(createdAt).getTime();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
const calculate = () => {
|
| 43 |
+
const elapsed = Date.now() - createdMs;
|
| 44 |
+
const rem = limit - elapsed;
|
| 45 |
setRemaining(rem);
|
| 46 |
};
|
| 47 |
|
| 48 |
calculate();
|
| 49 |
const timer = setInterval(calculate, 60 * 1000); // update every minute
|
| 50 |
return () => clearInterval(timer);
|
| 51 |
+
}, [priority, createdAt, isResolved]);
|
| 52 |
|
| 53 |
if (isResolved) {
|
| 54 |
return (
|
Frontend/src/admin/components/SLADashboard.jsx
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SLA Dashboard Widget — Real-time SLA monitoring panel
|
| 3 |
+
*
|
| 4 |
+
* Displays:
|
| 5 |
+
* - Active vs breached vs warning counts
|
| 6 |
+
* - Breach rate percentage
|
| 7 |
+
* - Per-priority breakdown
|
| 8 |
+
* - Recent escalations
|
| 9 |
+
* - Trend indicators
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import React, { useState, useEffect, useMemo } from 'react';
|
| 13 |
+
import {
|
| 14 |
+
Clock,
|
| 15 |
+
AlertTriangle,
|
| 16 |
+
ShieldCheck,
|
| 17 |
+
TrendingUp,
|
| 18 |
+
TrendingDown,
|
| 19 |
+
AlertCircle,
|
| 20 |
+
Activity,
|
| 21 |
+
RefreshCw,
|
| 22 |
+
ArrowUpRight,
|
| 23 |
+
ChevronRight,
|
| 24 |
+
Users,
|
| 25 |
+
Gauge,
|
| 26 |
+
} from 'lucide-react';
|
| 27 |
+
|
| 28 |
+
const PRIORITY_COLORS = {
|
| 29 |
+
critical: { bg: '#FEF2F2', text: '#DC2626', border: '#FECACA', dot: '#DC2626' },
|
| 30 |
+
high: { bg: '#FFF7ED', text: '#EA580C', border: '#FED7AA', dot: '#EA580C' },
|
| 31 |
+
medium: { bg: '#FEFCE8', text: '#CA8A04', border: '#FDE68A', dot: '#CA8A04' },
|
| 32 |
+
low: { bg: '#F0FDF4', text: '#16A34A', border: '#BBF7D0', dot: '#16A34A' },
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const API_BASE = import.meta.env.VITE_API_URL || 'https://helpdesk-ai-backend-iq0w.onrender.com';
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Fetch SLA dashboard stats from the backend.
|
| 39 |
+
*/
|
| 40 |
+
async function fetchSLAStats() {
|
| 41 |
+
const res = await fetch(`${API_BASE}/sla/stats`, {
|
| 42 |
+
headers: { 'Content-Type': 'application/json' },
|
| 43 |
+
});
|
| 44 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 45 |
+
return res.json();
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Fetch recent escalations.
|
| 50 |
+
*/
|
| 51 |
+
async function fetchEscalations(limit = 10) {
|
| 52 |
+
const res = await fetch(`${API_BASE}/sla/escalations?limit=${limit}`, {
|
| 53 |
+
headers: { 'Content-Type': 'application/json' },
|
| 54 |
+
});
|
| 55 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 56 |
+
return res.json();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Format seconds to human-readable duration.
|
| 61 |
+
*/
|
| 62 |
+
function formatDuration(seconds) {
|
| 63 |
+
if (!seconds && seconds !== 0) return '—';
|
| 64 |
+
if (seconds <= 0) return 'Overdue';
|
| 65 |
+
const hrs = Math.floor(seconds / 3600);
|
| 66 |
+
const mins = Math.floor((seconds % 3600) / 60);
|
| 67 |
+
if (hrs > 0) return `${hrs}h ${mins}m`;
|
| 68 |
+
return `${mins}m`;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Main SLA Dashboard Component
|
| 73 |
+
*/
|
| 74 |
+
export default function SLADashboard({ compact = false, onViewAll }) {
|
| 75 |
+
const [stats, setStats] = useState(null);
|
| 76 |
+
const [escalations, setEscalations] = useState([]);
|
| 77 |
+
const [loading, setLoading] = useState(true);
|
| 78 |
+
const [error, setError] = useState(null);
|
| 79 |
+
const [refreshing, setRefreshing] = useState(false);
|
| 80 |
+
|
| 81 |
+
const fetchData = async () => {
|
| 82 |
+
try {
|
| 83 |
+
setRefreshing(true);
|
| 84 |
+
const [statsData, escData] = await Promise.all([
|
| 85 |
+
fetchSLAStats(),
|
| 86 |
+
fetchEscalations(compact ? 5 : 10),
|
| 87 |
+
]);
|
| 88 |
+
setStats(statsData);
|
| 89 |
+
setEscalations(escData?.escalations || escData || []);
|
| 90 |
+
setError(null);
|
| 91 |
+
} catch (err) {
|
| 92 |
+
console.error('[SLA-Dashboard] Fetch error:', err);
|
| 93 |
+
setError(err.message);
|
| 94 |
+
} finally {
|
| 95 |
+
setLoading(false);
|
| 96 |
+
setRefreshing(false);
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
useEffect(() => { fetchData(); }, []);
|
| 101 |
+
|
| 102 |
+
// Auto-refresh every 60 seconds
|
| 103 |
+
useEffect(() => {
|
| 104 |
+
const interval = setInterval(fetchData, 60_000);
|
| 105 |
+
return () => clearInterval(interval);
|
| 106 |
+
}, []);
|
| 107 |
+
|
| 108 |
+
// ── KPIs ──────────────────────────────────────────────────────────────────
|
| 109 |
+
|
| 110 |
+
const kpis = useMemo(() => {
|
| 111 |
+
if (!stats) return [];
|
| 112 |
+
return [
|
| 113 |
+
{
|
| 114 |
+
label: 'Active Tickets',
|
| 115 |
+
value: stats.active ?? 0,
|
| 116 |
+
icon: Activity,
|
| 117 |
+
color: '#3b82f6',
|
| 118 |
+
bg: '#EFF6FF',
|
| 119 |
+
border: '#BFDBFE',
|
| 120 |
+
subtitle: 'Being monitored',
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
label: 'SLA Warnings',
|
| 124 |
+
value: stats.warning ?? 0,
|
| 125 |
+
icon: AlertTriangle,
|
| 126 |
+
color: '#F59E0B',
|
| 127 |
+
bg: '#FEFCE8',
|
| 128 |
+
border: '#FDE68A',
|
| 129 |
+
subtitle: 'Nearing breach',
|
| 130 |
+
pulse: (stats.warning ?? 0) > 0,
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
label: 'SLA Breached',
|
| 134 |
+
value: stats.breached ?? 0,
|
| 135 |
+
icon: AlertCircle,
|
| 136 |
+
color: '#DC2626',
|
| 137 |
+
bg: '#FEF2F2',
|
| 138 |
+
border: '#FECACA',
|
| 139 |
+
subtitle: 'Requires immediate action',
|
| 140 |
+
pulse: (stats.breached ?? 0) > 0,
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
label: 'Breach Rate',
|
| 144 |
+
value: stats.breach_rate != null ? `${stats.breach_rate}%` : '0%',
|
| 145 |
+
icon: Gauge,
|
| 146 |
+
color: (stats.breach_rate ?? 0) > 10 ? '#DC2626' : '#16A34A',
|
| 147 |
+
bg: (stats.breach_rate ?? 0) > 10 ? '#FEF2F2' : '#F0FDF4',
|
| 148 |
+
border: (stats.breach_rate ?? 0) > 10 ? '#FECACA' : '#BBF7D0',
|
| 149 |
+
subtitle: 'Of total tickets',
|
| 150 |
+
},
|
| 151 |
+
];
|
| 152 |
+
}, [stats]);
|
| 153 |
+
|
| 154 |
+
// ── Loading state ─────────────────────────────────────────────────────────
|
| 155 |
+
|
| 156 |
+
if (loading) {
|
| 157 |
+
return (
|
| 158 |
+
<div className="py-16 text-center">
|
| 159 |
+
<RefreshCw size={32} className="animate-spin mx-auto mb-4 text-emerald-500" />
|
| 160 |
+
<p className="text-sm text-gray-400 font-semibold uppercase tracking-wider">
|
| 161 |
+
Loading SLA Dashboard...
|
| 162 |
+
</p>
|
| 163 |
+
</div>
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
if (error) {
|
| 168 |
+
return (
|
| 169 |
+
<div className="py-16 text-center" style={{ border: '2px dashed #FECACA', borderRadius: '16px' }}>
|
| 170 |
+
<AlertCircle size={40} className="mx-auto mb-3 text-red-400" />
|
| 171 |
+
<h3 className="text-sm font-bold text-red-600 mb-1">Connection Error</h3>
|
| 172 |
+
<p className="text-xs text-gray-500">{error}</p>
|
| 173 |
+
<button
|
| 174 |
+
onClick={fetchData}
|
| 175 |
+
className="mt-4 px-4 py-2 bg-red-50 text-red-600 border border-red-200 rounded-lg text-xs font-bold uppercase tracking-wider hover:bg-red-100 transition-colors"
|
| 176 |
+
>
|
| 177 |
+
Retry
|
| 178 |
+
</button>
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// ── Empty state ───────────────────────────────────────────────────────────
|
| 184 |
+
|
| 185 |
+
if (!stats || (stats.active === 0 && stats.breached === 0)) {
|
| 186 |
+
return (
|
| 187 |
+
<div className="py-16 text-center" style={{ border: '2px dashed #D1FAE5', borderRadius: '16px' }}>
|
| 188 |
+
<ShieldCheck size={40} className="mx-auto mb-3 text-emerald-400" />
|
| 189 |
+
<h3 className="text-sm font-bold text-emerald-700 mb-1">All SLA Targets Met</h3>
|
| 190 |
+
<p className="text-xs text-gray-500">No active SLA violations.</p>
|
| 191 |
+
</div>
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// ── Render ────────────────────────────────────────────────────────────────
|
| 196 |
+
|
| 197 |
+
return (
|
| 198 |
+
<div className="space-y-6">
|
| 199 |
+
{/* Header with refresh */}
|
| 200 |
+
<div className="flex items-center justify-between">
|
| 201 |
+
<div className="flex items-center gap-2">
|
| 202 |
+
<Clock size={18} className="text-emerald-500" />
|
| 203 |
+
<h3 className="text-sm font-bold text-gray-800">SLA Monitoring</h3>
|
| 204 |
+
</div>
|
| 205 |
+
<button
|
| 206 |
+
onClick={fetchData}
|
| 207 |
+
disabled={refreshing}
|
| 208 |
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-gray-500 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors disabled:opacity-50"
|
| 209 |
+
>
|
| 210 |
+
<RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />
|
| 211 |
+
{refreshing ? 'Refreshing...' : 'Refresh'}
|
| 212 |
+
</button>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* KPI Cards */}
|
| 216 |
+
<div className={`grid gap-4 ${compact ? 'grid-cols-2' : 'grid-cols-2 lg:grid-cols-4'}`}>
|
| 217 |
+
{kpis.map((kpi, idx) => (
|
| 218 |
+
<div
|
| 219 |
+
key={idx}
|
| 220 |
+
className="rounded-xl p-4 border transition-all hover:shadow-md"
|
| 221 |
+
style={{
|
| 222 |
+
background: kpi.bg,
|
| 223 |
+
borderColor: kpi.border,
|
| 224 |
+
}}
|
| 225 |
+
>
|
| 226 |
+
<div className="flex items-center justify-between mb-3">
|
| 227 |
+
<kpi.icon size={20} style={{ color: kpi.color }} />
|
| 228 |
+
<div
|
| 229 |
+
className={`w-2 h-2 rounded-full ${kpi.pulse ? 'animate-pulse' : ''}`}
|
| 230 |
+
style={{ background: kpi.color }}
|
| 231 |
+
/>
|
| 232 |
+
</div>
|
| 233 |
+
<p className="text-2xl font-bold" style={{ color: kpi.color }}>
|
| 234 |
+
{kpi.value}
|
| 235 |
+
</p>
|
| 236 |
+
<p className="text-xs font-semibold text-gray-600 mt-1">{kpi.label}</p>
|
| 237 |
+
<p className="text-[10px] text-gray-400 mt-0.5">{kpi.subtitle}</p>
|
| 238 |
+
</div>
|
| 239 |
+
))}
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
{/* Per-Priority Breakdown */}
|
| 243 |
+
{stats?.by_priority && !compact && (
|
| 244 |
+
<div className="rounded-2xl border border-gray-100 bg-white p-5">
|
| 245 |
+
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
|
| 246 |
+
SLA Breakdown by Priority
|
| 247 |
+
</h4>
|
| 248 |
+
<div className="space-y-3">
|
| 249 |
+
{Object.entries(stats.by_priority).map(([priority, data]) => {
|
| 250 |
+
const colors = PRIORITY_COLORS[priority] || PRIORITY_COLORS.medium;
|
| 251 |
+
const total = data.total || 0;
|
| 252 |
+
const breached = data.breached || 0;
|
| 253 |
+
const warning = data.warning || 0;
|
| 254 |
+
const healthy = total - breached - warning;
|
| 255 |
+
const breachPct = total > 0 ? Math.round((breached / total) * 100) : 0;
|
| 256 |
+
|
| 257 |
+
return (
|
| 258 |
+
<div key={priority} className="flex items-center gap-3">
|
| 259 |
+
<span
|
| 260 |
+
className="text-[10px] font-bold uppercase tracking-wider min-w-[60px]"
|
| 261 |
+
style={{ color: colors.text }}
|
| 262 |
+
>
|
| 263 |
+
{priority}
|
| 264 |
+
</span>
|
| 265 |
+
<div className="flex-1 h-2.5 rounded-full bg-gray-100 overflow-hidden flex">
|
| 266 |
+
{healthy > 0 && (
|
| 267 |
+
<div
|
| 268 |
+
className="h-full transition-all duration-500"
|
| 269 |
+
style={{
|
| 270 |
+
width: `${total > 0 ? (healthy / total) * 100 : 0}%`,
|
| 271 |
+
background: '#16A34A',
|
| 272 |
+
}}
|
| 273 |
+
/>
|
| 274 |
+
)}
|
| 275 |
+
{warning > 0 && (
|
| 276 |
+
<div
|
| 277 |
+
className="h-full transition-all duration-500"
|
| 278 |
+
style={{
|
| 279 |
+
width: `${total > 0 ? (warning / total) * 100 : 0}%`,
|
| 280 |
+
background: '#F59E0B',
|
| 281 |
+
}}
|
| 282 |
+
/>
|
| 283 |
+
)}
|
| 284 |
+
{breached > 0 && (
|
| 285 |
+
<div
|
| 286 |
+
className="h-full transition-all duration-500"
|
| 287 |
+
style={{
|
| 288 |
+
width: `${total > 0 ? (breached / total) * 100 : 0}%`,
|
| 289 |
+
background: '#DC2626',
|
| 290 |
+
}}
|
| 291 |
+
/>
|
| 292 |
+
)}
|
| 293 |
+
</div>
|
| 294 |
+
<span className="text-xs font-semibold text-gray-600 min-w-[40px] text-right">
|
| 295 |
+
{total}
|
| 296 |
+
</span>
|
| 297 |
+
{breachPct > 0 && (
|
| 298 |
+
<span className="text-[10px] font-bold text-red-500 min-w-[36px] text-right">
|
| 299 |
+
{breachPct}%
|
| 300 |
+
</span>
|
| 301 |
+
)}
|
| 302 |
+
</div>
|
| 303 |
+
);
|
| 304 |
+
})}
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
)}
|
| 308 |
+
|
| 309 |
+
{/* Recent Escalations */}
|
| 310 |
+
{escalations.length > 0 && !compact && (
|
| 311 |
+
<div className="rounded-2xl border border-gray-100 bg-white p-5">
|
| 312 |
+
<div className="flex items-center justify-between mb-4">
|
| 313 |
+
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider">
|
| 314 |
+
Recent Escalations
|
| 315 |
+
</h4>
|
| 316 |
+
{onViewAll && (
|
| 317 |
+
<button
|
| 318 |
+
onClick={onViewAll}
|
| 319 |
+
className="text-xs font-semibold text-emerald-600 hover:text-emerald-700 flex items-center gap-1"
|
| 320 |
+
>
|
| 321 |
+
View All <ChevronRight size={12} />
|
| 322 |
+
</button>
|
| 323 |
+
)}
|
| 324 |
+
</div>
|
| 325 |
+
<div className="space-y-2">
|
| 326 |
+
{escalations.slice(0, compact ? 3 : 5).map((esc, idx) => {
|
| 327 |
+
const status = esc.sla_status || 'breached';
|
| 328 |
+
const level = esc.escalation_level || 0;
|
| 329 |
+
const isBreached = status === 'breached';
|
| 330 |
+
return (
|
| 331 |
+
<div
|
| 332 |
+
key={esc.id || idx}
|
| 333 |
+
className="flex items-center justify-between p-3 rounded-xl border transition-colors hover:bg-gray-50"
|
| 334 |
+
style={{
|
| 335 |
+
borderColor: isBreached ? '#FECACA' : '#FDE68A',
|
| 336 |
+
background: isBreached ? '#FFFBFB' : '#FFFEF9',
|
| 337 |
+
}}
|
| 338 |
+
>
|
| 339 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 340 |
+
<div
|
| 341 |
+
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
| 342 |
+
style={{ background: isBreached ? '#FEF2F2' : '#FEFCE8' }}
|
| 343 |
+
>
|
| 344 |
+
{isBreached
|
| 345 |
+
? <AlertCircle size={14} className="text-red-500" />
|
| 346 |
+
: <AlertTriangle size={14} className="text-amber-500" />
|
| 347 |
+
}
|
| 348 |
+
</div>
|
| 349 |
+
<div className="min-w-0">
|
| 350 |
+
<p className="text-xs font-semibold text-gray-800 truncate">
|
| 351 |
+
{esc.ticket_subject || `Ticket #${(esc.ticket_id || '').toString().slice(0, 8)}`}
|
| 352 |
+
</p>
|
| 353 |
+
<p className="text-[10px] text-gray-400">
|
| 354 |
+
L{level} · {esc.assigned_team || 'Unassigned'}
|
| 355 |
+
{esc.remaining_seconds != null && ` · ${formatDuration(esc.remaining_seconds)}`}
|
| 356 |
+
</p>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
<span
|
| 360 |
+
className={`text-[10px] font-bold px-2 py-1 rounded-full uppercase tracking-wider flex-shrink-0 ${
|
| 361 |
+
isBreached
|
| 362 |
+
? 'bg-red-50 text-red-600 border border-red-200'
|
| 363 |
+
: 'bg-amber-50 text-amber-600 border border-amber-200'
|
| 364 |
+
}`}
|
| 365 |
+
>
|
| 366 |
+
{status}
|
| 367 |
+
</span>
|
| 368 |
+
</div>
|
| 369 |
+
);
|
| 370 |
+
})}
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
)}
|
| 374 |
+
</div>
|
| 375 |
+
);
|
| 376 |
+
}
|
Frontend/src/admin/pages/SLAPage.jsx
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SLAPage — Dedicated SLA Monitoring & Management Page
|
| 3 |
+
*
|
| 4 |
+
* Features:
|
| 5 |
+
* - Real-time SLA dashboard with KPIs
|
| 6 |
+
* - Per-priority breakdown visualization
|
| 7 |
+
* - Active violations list with escalation tracking
|
| 8 |
+
* - Escalation timeline / audit log
|
| 9 |
+
* - Alert configuration panel
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
| 13 |
+
import {
|
| 14 |
+
Clock,
|
| 15 |
+
AlertTriangle,
|
| 16 |
+
AlertCircle,
|
| 17 |
+
ShieldCheck,
|
| 18 |
+
RefreshCw,
|
| 19 |
+
ChevronDown,
|
| 20 |
+
ChevronUp,
|
| 21 |
+
Filter,
|
| 22 |
+
Bell,
|
| 23 |
+
Settings,
|
| 24 |
+
ExternalLink,
|
| 25 |
+
Gauge,
|
| 26 |
+
Activity,
|
| 27 |
+
Users,
|
| 28 |
+
TrendingUp,
|
| 29 |
+
} from 'lucide-react';
|
| 30 |
+
import SLADashboard from '../components/SLADashboard';
|
| 31 |
+
import SLABadge from '../components/SLABadge';
|
| 32 |
+
|
| 33 |
+
const API_BASE = import.meta.env.VITE_API_URL || 'https://helpdesk-ai-backend-iq0w.onrender.com';
|
| 34 |
+
|
| 35 |
+
// ── Data fetching helpers ────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
async function fetchSLATickets(status = 'all') {
|
| 38 |
+
const params = status !== 'all' ? `?status=${status}` : '';
|
| 39 |
+
const res = await fetch(`${API_BASE}/sla/tickets${params}`, {
|
| 40 |
+
headers: { 'Content-Type': 'application/json' },
|
| 41 |
+
});
|
| 42 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 43 |
+
return res.json();
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
async function fetchEscalations(limit = 50) {
|
| 47 |
+
const res = await fetch(`${API_BASE}/sla/escalations?limit=${limit}`, {
|
| 48 |
+
headers: { 'Content-Type': 'application/json' },
|
| 49 |
+
});
|
| 50 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 51 |
+
return res.json();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async function fetchSLAPolicies() {
|
| 55 |
+
const res = await fetch(`${API_BASE}/sla/policies`, {
|
| 56 |
+
headers: { 'Content-Type': 'application/json' },
|
| 57 |
+
});
|
| 58 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 59 |
+
return res.json();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// ── Status styles ────────────────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
const STATUS_STYLES = {
|
| 65 |
+
breached: { bg: '#FEF2F2', text: '#DC2626', border: '#FECACA', icon: AlertCircle, label: 'Breached' },
|
| 66 |
+
warning: { bg: '#FEFCE8', text: '#CA8A04', border: '#FDE68A', icon: AlertTriangle, label: 'Warning' },
|
| 67 |
+
active: { bg: '#F0FDF4', text: '#16A34A', border: '#BBF7D0', icon: Activity, label: 'Active' },
|
| 68 |
+
met: { bg: '#F1F5F9', text: '#64748B', border: '#E2E8F0', icon: ShieldCheck, label: 'SLA Met' },
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const PRIORITY_STYLES = {
|
| 72 |
+
critical: { bg: '#FEF2F2', text: '#DC2626', border: '#FECACA' },
|
| 73 |
+
high: { bg: '#FFF7ED', text: '#EA580C', border: '#FED7AA' },
|
| 74 |
+
medium: { bg: '#FEFCE8', text: '#CA8A04', border: '#FDE68A' },
|
| 75 |
+
low: { bg: '#F0FDF4', text: '#16A34A', border: '#BBF7D0' },
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
function formatDuration(seconds) {
|
| 79 |
+
if (seconds == null) return '—';
|
| 80 |
+
if (seconds <= 0) return 'Overdue';
|
| 81 |
+
const hrs = Math.floor(seconds / 3600);
|
| 82 |
+
const mins = Math.floor((seconds % 3600) / 60);
|
| 83 |
+
if (hrs > 0) return `${hrs}h ${mins}m`;
|
| 84 |
+
return `${mins}m`;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function formatDate(iso) {
|
| 88 |
+
if (!iso) return '—';
|
| 89 |
+
const d = new Date(iso);
|
| 90 |
+
const now = new Date();
|
| 91 |
+
const diffMs = now - d;
|
| 92 |
+
const diffMin = Math.floor(diffMs / 60000);
|
| 93 |
+
if (diffMin < 1) return 'Just now';
|
| 94 |
+
if (diffMin < 60) return `${diffMin}m ago`;
|
| 95 |
+
const diffHr = Math.floor(diffMin / 60);
|
| 96 |
+
if (diffHr < 24) return `${diffHr}h ago`;
|
| 97 |
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ── SLAPage Component ────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
export default function SLAPage() {
|
| 103 |
+
const [activeTab, setActiveTab] = useState('dashboard');
|
| 104 |
+
const [tickets, setTickets] = useState([]);
|
| 105 |
+
const [escalations, setEscalations] = useState([]);
|
| 106 |
+
const [policies, setPolicies] = useState([]);
|
| 107 |
+
const [loading, setLoading] = useState(true);
|
| 108 |
+
const [error, setError] = useState(null);
|
| 109 |
+
const [filterStatus, setFilterStatus] = useState('all');
|
| 110 |
+
const [filterPriority, setFilterPriority] = useState('all');
|
| 111 |
+
const [expandedEsc, setExpandedEsc] = useState(null);
|
| 112 |
+
|
| 113 |
+
// ── Data Loading ────────────────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
const loadData = useCallback(async () => {
|
| 116 |
+
setLoading(true);
|
| 117 |
+
setError(null);
|
| 118 |
+
try {
|
| 119 |
+
const [ticketData, escData, policyData] = await Promise.all([
|
| 120 |
+
fetchSLATickets(filterStatus),
|
| 121 |
+
fetchEscalations(),
|
| 122 |
+
fetchSLAPolicies(),
|
| 123 |
+
]);
|
| 124 |
+
setTickets(Array.isArray(ticketData) ? ticketData : ticketData?.tickets || []);
|
| 125 |
+
setEscalations(Array.isArray(escData) ? escData : escData?.escalations || []);
|
| 126 |
+
setPolicies(Array.isArray(policyData) ? policyData : policyData?.policies || []);
|
| 127 |
+
} catch (err) {
|
| 128 |
+
console.error('[SLAPage] Fetch error:', err);
|
| 129 |
+
setError(err.message);
|
| 130 |
+
} finally {
|
| 131 |
+
setLoading(false);
|
| 132 |
+
}
|
| 133 |
+
}, [filterStatus]);
|
| 134 |
+
|
| 135 |
+
useEffect(() => { loadData(); }, [loadData]);
|
| 136 |
+
|
| 137 |
+
// ── Filtered tickets ────────────────────────────────────────────────────────
|
| 138 |
+
|
| 139 |
+
const filteredTickets = useMemo(() => {
|
| 140 |
+
let result = tickets;
|
| 141 |
+
if (filterPriority !== 'all') {
|
| 142 |
+
result = result.filter(t => (t.priority || '').toLowerCase() === filterPriority);
|
| 143 |
+
}
|
| 144 |
+
// Sort by severity: breached first, then warning, then active
|
| 145 |
+
const order = { breached: 0, warning: 1, active: 2, met: 3 };
|
| 146 |
+
result = [...result].sort((a, b) => {
|
| 147 |
+
const aOrder = order[a.sla_status] ?? 99;
|
| 148 |
+
const bOrder = order[b.sla_status] ?? 99;
|
| 149 |
+
return aOrder - bOrder;
|
| 150 |
+
});
|
| 151 |
+
return result;
|
| 152 |
+
}, [tickets, filterPriority]);
|
| 153 |
+
|
| 154 |
+
// ── Stats card component ────────────────────────────────────────────────────
|
| 155 |
+
|
| 156 |
+
const StatCard = ({ label, value, icon: Icon, color, bg, border, subtitle, pulse }) => (
|
| 157 |
+
<div
|
| 158 |
+
className="rounded-xl p-5 border transition-all hover:shadow-md"
|
| 159 |
+
style={{ background: bg, borderColor: border }}
|
| 160 |
+
>
|
| 161 |
+
<div className="flex items-center justify-between mb-3">
|
| 162 |
+
<Icon size={22} style={{ color }} />
|
| 163 |
+
{pulse && <span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse" />}
|
| 164 |
+
</div>
|
| 165 |
+
<p className="text-3xl font-bold" style={{ color }}>{value}</p>
|
| 166 |
+
<p className="text-sm font-semibold text-gray-600 mt-1">{label}</p>
|
| 167 |
+
{subtitle && <p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>}
|
| 168 |
+
</div>
|
| 169 |
+
);
|
| 170 |
+
|
| 171 |
+
// ── Tab navigation ──────────────────────────────────────────────────────────
|
| 172 |
+
|
| 173 |
+
const tabs = [
|
| 174 |
+
{ id: 'dashboard', label: 'Dashboard', icon: Gauge },
|
| 175 |
+
{ id: 'violations', label: 'Violations', icon: AlertTriangle },
|
| 176 |
+
{ id: 'escalations', label: 'Escalations', icon: Bell },
|
| 177 |
+
{ id: 'policies', label: 'SLA Policies', icon: Settings },
|
| 178 |
+
];
|
| 179 |
+
|
| 180 |
+
// ── Render filters bar ──────────────────────────────────────────────────────
|
| 181 |
+
|
| 182 |
+
const FiltersBar = () => (
|
| 183 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 184 |
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-gray-200 rounded-lg">
|
| 185 |
+
<Filter size={14} className="text-gray-400" />
|
| 186 |
+
<select
|
| 187 |
+
value={filterStatus}
|
| 188 |
+
onChange={e => setFilterStatus(e.target.value)}
|
| 189 |
+
className="text-xs font-semibold text-gray-600 bg-transparent border-none outline-none cursor-pointer"
|
| 190 |
+
>
|
| 191 |
+
<option value="all">All Status</option>
|
| 192 |
+
<option value="breached">Breached</option>
|
| 193 |
+
<option value="warning">Warning</option>
|
| 194 |
+
<option value="active">Active</option>
|
| 195 |
+
<option value="met">Met</option>
|
| 196 |
+
</select>
|
| 197 |
+
</div>
|
| 198 |
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-gray-200 rounded-lg">
|
| 199 |
+
<Filter size={14} className="text-gray-400" />
|
| 200 |
+
<select
|
| 201 |
+
value={filterPriority}
|
| 202 |
+
onChange={e => setFilterPriority(e.target.value)}
|
| 203 |
+
className="text-xs font-semibold text-gray-600 bg-transparent border-none outline-none cursor-pointer"
|
| 204 |
+
>
|
| 205 |
+
<option value="all">All Priorities</option>
|
| 206 |
+
<option value="critical">Critical</option>
|
| 207 |
+
<option value="high">High</option>
|
| 208 |
+
<option value="medium">Medium</option>
|
| 209 |
+
<option value="low">Low</option>
|
| 210 |
+
</select>
|
| 211 |
+
</div>
|
| 212 |
+
<button
|
| 213 |
+
onClick={loadData}
|
| 214 |
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-gray-500 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
| 215 |
+
>
|
| 216 |
+
<RefreshCw size={12} /> Refresh
|
| 217 |
+
</button>
|
| 218 |
+
</div>
|
| 219 |
+
);
|
| 220 |
+
|
| 221 |
+
// ── Render: Dashboard Tab ───────────────────────────────────────────────────
|
| 222 |
+
|
| 223 |
+
const renderDashboard = () => <SLADashboard />;
|
| 224 |
+
|
| 225 |
+
// ── Render: Violations Tab ──────────────────────────────────────────────────
|
| 226 |
+
|
| 227 |
+
const renderViolations = () => {
|
| 228 |
+
if (loading) {
|
| 229 |
+
return (
|
| 230 |
+
<div className="py-24 text-center">
|
| 231 |
+
<RefreshCw size={32} className="animate-spin mx-auto mb-4 text-emerald-500" />
|
| 232 |
+
<p className="text-sm text-gray-400 font-semibold">Loading violations...</p>
|
| 233 |
+
</div>
|
| 234 |
+
);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
const activeViolations = filteredTickets.filter(
|
| 238 |
+
t => t.sla_status === 'breached' || t.sla_status === 'warning'
|
| 239 |
+
);
|
| 240 |
+
|
| 241 |
+
if (activeViolations.length === 0) {
|
| 242 |
+
return (
|
| 243 |
+
<div className="py-24 text-center" style={{ border: '2px dashed #D1FAE5', borderRadius: '16px' }}>
|
| 244 |
+
<ShieldCheck size={48} className="mx-auto mb-3 text-emerald-400" />
|
| 245 |
+
<h3 className="text-lg font-bold text-emerald-700">No Active Violations</h3>
|
| 246 |
+
<p className="text-sm text-gray-500 mt-1">All tickets within SLA limits.</p>
|
| 247 |
+
</div>
|
| 248 |
+
);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
return (
|
| 252 |
+
<div className="space-y-3">
|
| 253 |
+
{activeViolations.map(ticket => {
|
| 254 |
+
const pStyle = PRIORITY_STYLES[ticket.priority?.toLowerCase()] || PRIORITY_STYLES.medium;
|
| 255 |
+
const sStyle = STATUS_STYLES[ticket.sla_status] || STATUS_STYLES.active;
|
| 256 |
+
|
| 257 |
+
return (
|
| 258 |
+
<div
|
| 259 |
+
key={ticket.id}
|
| 260 |
+
className="flex items-center justify-between p-4 rounded-xl border bg-white transition-all hover:shadow-md"
|
| 261 |
+
style={{ borderColor: sStyle.border }}
|
| 262 |
+
>
|
| 263 |
+
<div className="flex items-center gap-4 min-w-0 flex-1">
|
| 264 |
+
<sStyle.icon size={20} style={{ color: sStyle.text }} className="flex-shrink-0" />
|
| 265 |
+
<div className="min-w-0 flex-1">
|
| 266 |
+
<div className="flex items-center gap-2">
|
| 267 |
+
<span className="text-sm font-semibold text-gray-800 truncate">
|
| 268 |
+
{ticket.subject || ticket.summary || 'Untitled'}
|
| 269 |
+
</span>
|
| 270 |
+
<span
|
| 271 |
+
className="text-[10px] font-bold px-2 py-0.5 rounded uppercase"
|
| 272 |
+
style={{ background: pStyle.bg, color: pStyle.text, border: `1px solid ${pStyle.border}` }}
|
| 273 |
+
>
|
| 274 |
+
{ticket.priority || 'MEDIUM'}
|
| 275 |
+
</span>
|
| 276 |
+
</div>
|
| 277 |
+
<div className="flex items-center gap-3 mt-1">
|
| 278 |
+
<span className="text-xs text-gray-400">
|
| 279 |
+
#{((ticket.ticket_id || ticket.id) || '').toString().slice(0, 8)}
|
| 280 |
+
</span>
|
| 281 |
+
<span className="text-xs text-gray-400">{ticket.assigned_team || 'Unassigned'}</span>
|
| 282 |
+
<span className="text-xs text-gray-400">
|
| 283 |
+
{formatDuration(ticket.remaining_seconds)}
|
| 284 |
+
</span>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 289 |
+
<SLABadge
|
| 290 |
+
priority={ticket.priority}
|
| 291 |
+
createdAt={ticket.created_at}
|
| 292 |
+
status={ticket.status}
|
| 293 |
+
compact
|
| 294 |
+
/>
|
| 295 |
+
<a
|
| 296 |
+
href={`/admin/ticket/${ticket.ticket_id || ticket.id}`}
|
| 297 |
+
className="p-2 text-gray-400 hover:text-emerald-500 transition-colors"
|
| 298 |
+
>
|
| 299 |
+
<ExternalLink size={14} />
|
| 300 |
+
</a>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
);
|
| 304 |
+
})}
|
| 305 |
+
</div>
|
| 306 |
+
);
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
+
// ── Render: Escalations Tab ─────────────────────────────────────────────────
|
| 310 |
+
|
| 311 |
+
const renderEscalations = () => {
|
| 312 |
+
if (loading) {
|
| 313 |
+
return (
|
| 314 |
+
<div className="py-24 text-center">
|
| 315 |
+
<RefreshCw size={32} className="animate-spin mx-auto mb-4 text-emerald-500" />
|
| 316 |
+
<p className="text-sm text-gray-400 font-semibold">Loading escalations...</p>
|
| 317 |
+
</div>
|
| 318 |
+
);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
if (escalations.length === 0) {
|
| 322 |
+
return (
|
| 323 |
+
<div className="py-24 text-center" style={{ border: '2px dashed #E5E7EB', borderRadius: '16px' }}>
|
| 324 |
+
<Bell size={40} className="mx-auto mb-3 text-gray-300" />
|
| 325 |
+
<h3 className="text-sm font-bold text-gray-500">No Escalations Logged</h3>
|
| 326 |
+
<p className="text-xs text-gray-400 mt-1">All tickets resolved within SLA.</p>
|
| 327 |
+
</div>
|
| 328 |
+
);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
return (
|
| 332 |
+
<div className="space-y-2">
|
| 333 |
+
{escalations.map((esc, idx) => {
|
| 334 |
+
const isBreached = esc.sla_status === 'breached';
|
| 335 |
+
const isExpanded = expandedEsc === (esc.id || idx);
|
| 336 |
+
const level = esc.escalation_level || 0;
|
| 337 |
+
|
| 338 |
+
return (
|
| 339 |
+
<div
|
| 340 |
+
key={esc.id || idx}
|
| 341 |
+
className="rounded-xl border bg-white overflow-hidden transition-all"
|
| 342 |
+
style={{ borderColor: isBreached ? '#FECACA' : '#E5E7EB' }}
|
| 343 |
+
>
|
| 344 |
+
<button
|
| 345 |
+
onClick={() => setExpandedEsc(isExpanded ? null : (esc.id || idx))}
|
| 346 |
+
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition-colors"
|
| 347 |
+
>
|
| 348 |
+
<div className="flex items-center gap-3 min-w-0 flex-1">
|
| 349 |
+
<div
|
| 350 |
+
className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0"
|
| 351 |
+
style={{ background: isBreached ? '#FEF2F2' : '#FEFCE8' }}
|
| 352 |
+
>
|
| 353 |
+
{isBreached
|
| 354 |
+
? <AlertCircle size={16} className="text-red-500" />
|
| 355 |
+
: <AlertTriangle size={16} className="text-amber-500" />
|
| 356 |
+
}
|
| 357 |
+
</div>
|
| 358 |
+
<div className="min-w-0 flex-1">
|
| 359 |
+
<p className="text-sm font-semibold text-gray-800 truncate">
|
| 360 |
+
{esc.ticket_subject || `Ticket #${((esc.ticket_id || '') + '').slice(0, 8)}`}
|
| 361 |
+
</p>
|
| 362 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 363 |
+
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded"
|
| 364 |
+
style={{
|
| 365 |
+
background: isBreached ? '#FEF2F2' : '#FEFCE8',
|
| 366 |
+
color: isBreached ? '#DC2626' : '#CA8A04',
|
| 367 |
+
}}
|
| 368 |
+
>
|
| 369 |
+
L{level}
|
| 370 |
+
</span>
|
| 371 |
+
<span className="text-xs text-gray-400">{esc.assigned_team || 'Unassigned'}</span>
|
| 372 |
+
<span className="text-xs text-gray-400">{formatDate(esc.triggered_at)}</span>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 377 |
+
<span
|
| 378 |
+
className={`text-[10px] font-bold px-2 py-1 rounded-full uppercase ${
|
| 379 |
+
isBreached ? 'bg-red-50 text-red-600' : 'bg-amber-50 text-amber-600'
|
| 380 |
+
}`}
|
| 381 |
+
>
|
| 382 |
+
{esc.sla_status}
|
| 383 |
+
</span>
|
| 384 |
+
{isExpanded ? <ChevronUp size={14} className="text-gray-400" /> : <ChevronDown size={14} className="text-gray-400" />}
|
| 385 |
+
</div>
|
| 386 |
+
</button>
|
| 387 |
+
|
| 388 |
+
{isExpanded && (
|
| 389 |
+
<div className="px-4 pb-4 pt-0 border-t border-gray-100 bg-gray-50/50">
|
| 390 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-3">
|
| 391 |
+
<div>
|
| 392 |
+
<p className="text-[10px] font-semibold text-gray-400 uppercase">Status</p>
|
| 393 |
+
<p className="text-xs font-bold text-gray-700 mt-1">{esc.sla_status}</p>
|
| 394 |
+
</div>
|
| 395 |
+
<div>
|
| 396 |
+
<p className="text-[10px] font-semibold text-gray-400 uppercase">Level</p>
|
| 397 |
+
<p className="text-xs font-bold text-gray-700 mt-1">{level}</p>
|
| 398 |
+
</div>
|
| 399 |
+
<div>
|
| 400 |
+
<p className="text-[10px] font-semibold text-gray-400 uppercase">Remaining</p>
|
| 401 |
+
<p className="text-xs font-bold text-gray-700 mt-1">{formatDuration(esc.remaining_seconds)}</p>
|
| 402 |
+
</div>
|
| 403 |
+
<div>
|
| 404 |
+
<p className="text-[10px] font-semibold text-gray-400 uppercase">Priority</p>
|
| 405 |
+
<p className="text-xs font-bold text-gray-700 mt-1">{esc.priority || 'N/A'}</p>
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
{esc.notification_channels && esc.notification_channels.length > 0 && (
|
| 409 |
+
<div className="mt-3 flex items-center gap-2">
|
| 410 |
+
<span className="text-[10px] font-semibold text-gray-400 uppercase">Channels:</span>
|
| 411 |
+
{esc.notification_channels.map((ch, i) => (
|
| 412 |
+
<span
|
| 413 |
+
key={i}
|
| 414 |
+
className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
|
| 415 |
+
ch.includes('OK') ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-500'
|
| 416 |
+
}`}
|
| 417 |
+
>
|
| 418 |
+
{ch}
|
| 419 |
+
</span>
|
| 420 |
+
))}
|
| 421 |
+
</div>
|
| 422 |
+
)}
|
| 423 |
+
</div>
|
| 424 |
+
)}
|
| 425 |
+
</div>
|
| 426 |
+
);
|
| 427 |
+
})}
|
| 428 |
+
</div>
|
| 429 |
+
);
|
| 430 |
+
};
|
| 431 |
+
|
| 432 |
+
// ── Render: Policies Tab ────────────────────────────────────────────────────
|
| 433 |
+
|
| 434 |
+
const renderPolicies = () => {
|
| 435 |
+
if (policies.length === 0) {
|
| 436 |
+
return (
|
| 437 |
+
<div className="py-16 text-center" style={{ border: '2px dashed #E5E7EB', borderRadius: '16px' }}>
|
| 438 |
+
<Settings size={40} className="mx-auto mb-3 text-gray-300" />
|
| 439 |
+
<h3 className="text-sm font-bold text-gray-500">Default Policies Active</h3>
|
| 440 |
+
<p className="text-xs text-gray-400 mt-1">Custom policies can be configured from the database.</p>
|
| 441 |
+
</div>
|
| 442 |
+
);
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
return (
|
| 446 |
+
<div className="overflow-x-auto">
|
| 447 |
+
<table className="w-full border-collapse">
|
| 448 |
+
<thead>
|
| 449 |
+
<tr style={{ background: '#F8FAFC', borderBottom: '1px solid #E2E8F0' }}>
|
| 450 |
+
<th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">Priority</th>
|
| 451 |
+
<th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">Max Hours</th>
|
| 452 |
+
<th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">Warning At</th>
|
| 453 |
+
<th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">Auto Escalate</th>
|
| 454 |
+
<th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">L2 Escalation</th>
|
| 455 |
+
<th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">L3 Escalation</th>
|
| 456 |
+
</tr>
|
| 457 |
+
</thead>
|
| 458 |
+
<tbody>
|
| 459 |
+
{policies.map((p, idx) => {
|
| 460 |
+
const colors = PRIORITY_STYLES[p.priority] || PRIORITY_STYLES.medium;
|
| 461 |
+
return (
|
| 462 |
+
<tr key={p.id || idx} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
| 463 |
+
<td className="p-4">
|
| 464 |
+
<span
|
| 465 |
+
className="text-xs font-bold uppercase px-3 py-1 rounded-full"
|
| 466 |
+
style={{ background: colors.bg, color: colors.text, border: `1px solid ${colors.border}` }}
|
| 467 |
+
>
|
| 468 |
+
{p.priority}
|
| 469 |
+
</span>
|
| 470 |
+
</td>
|
| 471 |
+
<td className="p-4 text-sm font-semibold text-gray-700">{p.max_hours}h</td>
|
| 472 |
+
<td className="p-4 text-sm font-semibold text-gray-700">{Math.round((p.warning_pct || 0.75) * 100)}%</td>
|
| 473 |
+
<td className="p-4">
|
| 474 |
+
<span
|
| 475 |
+
className={`text-xs font-bold px-2 py-0.5 rounded ${
|
| 476 |
+
p.auto_escalate ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500'
|
| 477 |
+
}`}
|
| 478 |
+
>
|
| 479 |
+
{p.auto_escalate ? 'Yes' : 'No'}
|
| 480 |
+
</span>
|
| 481 |
+
</td>
|
| 482 |
+
<td className="p-4 text-sm text-gray-600">{p.l2_after_minutes > 0 ? `${p.l2_after_minutes}m` : 'Immediate'}</td>
|
| 483 |
+
<td className="p-4 text-sm text-gray-600">{p.l3_after_minutes > 0 ? `${p.l3_after_minutes}m` : '—'}</td>
|
| 484 |
+
</tr>
|
| 485 |
+
);
|
| 486 |
+
})}
|
| 487 |
+
</tbody>
|
| 488 |
+
</table>
|
| 489 |
+
</div>
|
| 490 |
+
);
|
| 491 |
+
};
|
| 492 |
+
|
| 493 |
+
// ── Main Render ────────────────────────────────────────────────────────────
|
| 494 |
+
|
| 495 |
+
return (
|
| 496 |
+
<div style={{ background: '#F8FAF9', minHeight: '100vh', paddingBottom: '60px' }} className="space-y-8 -m-6 p-6 md:-m-10 md:p-10">
|
| 497 |
+
{/* Header */}
|
| 498 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
| 499 |
+
<div>
|
| 500 |
+
<h1 className="text-2xl font-extrabold text-gray-900" style={{ fontFamily: 'Syne, sans-serif' }}>
|
| 501 |
+
SLA Monitor
|
| 502 |
+
</h1>
|
| 503 |
+
<p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
| 504 |
+
<span className="w-2 h-2 rounded-full bg-emerald-500 inline-block animate-pulse" />
|
| 505 |
+
Service-Level Agreement Management
|
| 506 |
+
</p>
|
| 507 |
+
</div>
|
| 508 |
+
<FiltersBar />
|
| 509 |
+
</div>
|
| 510 |
+
|
| 511 |
+
{/* Tab Navigation */}
|
| 512 |
+
<div className="flex gap-1 p-1 bg-white rounded-xl border border-gray-100 w-fit">
|
| 513 |
+
{tabs.map(tab => (
|
| 514 |
+
<button
|
| 515 |
+
key={tab.id}
|
| 516 |
+
onClick={() => setActiveTab(tab.id)}
|
| 517 |
+
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
|
| 518 |
+
activeTab === tab.id
|
| 519 |
+
? 'bg-emerald-500 text-white shadow-sm'
|
| 520 |
+
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
| 521 |
+
}`}
|
| 522 |
+
>
|
| 523 |
+
<tab.icon size={14} />
|
| 524 |
+
{tab.label}
|
| 525 |
+
</button>
|
| 526 |
+
))}
|
| 527 |
+
</div>
|
| 528 |
+
|
| 529 |
+
{/* Tab Content */}
|
| 530 |
+
<div>
|
| 531 |
+
{activeTab === 'dashboard' && renderDashboard()}
|
| 532 |
+
{activeTab === 'violations' && renderViolations()}
|
| 533 |
+
{activeTab === 'escalations' && renderEscalations()}
|
| 534 |
+
{activeTab === 'policies' && renderPolicies()}
|
| 535 |
+
</div>
|
| 536 |
+
</div>
|
| 537 |
+
);
|
| 538 |
+
}
|
Frontend/src/components/shared/DuplicateWarningBanner.jsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* DuplicateWarningBanner — Animated warning card shown when a potential
|
| 3 |
+
* duplicate ticket is detected during ticket creation.
|
| 4 |
+
*
|
| 5 |
+
* Features:
|
| 6 |
+
* - Animated slide-in warning with similarity score
|
| 7 |
+
* - Link to parent ticket
|
| 8 |
+
* - "Subscribe to existing" vs "Create anyway" actions
|
| 9 |
+
* - Matches the HELPDESK.AI design system (glass morphism, gradient)
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import React, { useState, useEffect } from 'react';
|
| 13 |
+
import { useNavigate } from 'react-router-dom';
|
| 14 |
+
import {
|
| 15 |
+
AlertTriangle,
|
| 16 |
+
Copy,
|
| 17 |
+
Eye,
|
| 18 |
+
Bell,
|
| 19 |
+
X,
|
| 20 |
+
ExternalLink,
|
| 21 |
+
ChevronDown,
|
| 22 |
+
ChevronUp,
|
| 23 |
+
Users,
|
| 24 |
+
Clock,
|
| 25 |
+
ArrowRight,
|
| 26 |
+
Send,
|
| 27 |
+
} from 'lucide-react';
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Format similarity as percentage with color.
|
| 31 |
+
*/
|
| 32 |
+
function formatSimilarity(score) {
|
| 33 |
+
if (score == null) return '—';
|
| 34 |
+
const pct = Math.round(score * 100);
|
| 35 |
+
if (pct >= 95) return { value: `${pct}%`, color: '#DC2626' };
|
| 36 |
+
if (pct >= 85) return { value: `${pct}%`, color: '#EA580C' };
|
| 37 |
+
if (pct >= 75) return { value: `${pct}%`, color: '#CA8A04' };
|
| 38 |
+
return { value: `${pct}%`, color: '#16A34A' };
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Format an ISO date string to a relative time.
|
| 43 |
+
*/
|
| 44 |
+
function formatDate(iso) {
|
| 45 |
+
if (!iso) return '';
|
| 46 |
+
const d = new Date(iso);
|
| 47 |
+
const now = new Date();
|
| 48 |
+
const diffMs = now - d;
|
| 49 |
+
const diffHr = Math.floor(diffMs / (1000 * 60 * 60));
|
| 50 |
+
if (diffHr < 1) return 'just now';
|
| 51 |
+
if (diffHr < 24) return `${diffHr}h ago`;
|
| 52 |
+
return `${Math.floor(diffHr / 24)}d ago`;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* DuplicateWarningBanner
|
| 57 |
+
*
|
| 58 |
+
* Props:
|
| 59 |
+
* - duplicate: { is_duplicate, duplicate_ticket_id, parent_subject, similarity, candidates }
|
| 60 |
+
* - onSubscribe: () => void — User wants to subscribe to existing ticket
|
| 61 |
+
* - onCreateAnyway: () => void — User wants to create ticket regardless
|
| 62 |
+
* - onDismiss: () => void — Dismiss warning
|
| 63 |
+
* - ticketId: string — ID of the current ticket being created
|
| 64 |
+
*/
|
| 65 |
+
export default function DuplicateWarningBanner({
|
| 66 |
+
duplicate,
|
| 67 |
+
onSubscribe,
|
| 68 |
+
onCreateAnyway,
|
| 69 |
+
onDismiss,
|
| 70 |
+
ticketId,
|
| 71 |
+
}) {
|
| 72 |
+
const navigate = useNavigate();
|
| 73 |
+
const [expanded, setExpanded] = useState(false);
|
| 74 |
+
const [visible, setVisible] = useState(false);
|
| 75 |
+
|
| 76 |
+
// Animate in on mount
|
| 77 |
+
useEffect(() => {
|
| 78 |
+
const timer = setTimeout(() => setVisible(true), 100);
|
| 79 |
+
return () => clearTimeout(timer);
|
| 80 |
+
}, []);
|
| 81 |
+
|
| 82 |
+
if (!duplicate || !duplicate.is_duplicate) return null;
|
| 83 |
+
|
| 84 |
+
const { similarity, parent_subject, duplicate_ticket_id, candidates } = duplicate;
|
| 85 |
+
const sim = formatSimilarity(similarity);
|
| 86 |
+
const extraCandidates = (candidates || []).filter(
|
| 87 |
+
c => c.id !== duplicate_ticket_id
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
<div
|
| 92 |
+
className={`transition-all duration-500 ease-out ${
|
| 93 |
+
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
|
| 94 |
+
}`}
|
| 95 |
+
>
|
| 96 |
+
<div
|
| 97 |
+
className="rounded-2xl border-2 overflow-hidden"
|
| 98 |
+
style={{
|
| 99 |
+
borderColor: '#FECACA',
|
| 100 |
+
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFFBEB 100%)',
|
| 101 |
+
boxShadow: '0 8px 32px rgba(239, 68, 68, 0.1)',
|
| 102 |
+
}}
|
| 103 |
+
>
|
| 104 |
+
{/* Warning Header */}
|
| 105 |
+
<div className="p-5 flex items-start gap-4">
|
| 106 |
+
{/* Icon */}
|
| 107 |
+
<div
|
| 108 |
+
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 animate-pulse"
|
| 109 |
+
style={{ background: '#FEF2F2', border: '1px solid #FECACA' }}
|
| 110 |
+
>
|
| 111 |
+
<AlertTriangle size={22} className="text-red-500" />
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
{/* Content */}
|
| 115 |
+
<div className="flex-1 min-w-0">
|
| 116 |
+
<div className="flex items-start justify-between gap-2">
|
| 117 |
+
<div>
|
| 118 |
+
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
|
| 119 |
+
<Copy size={16} className="text-amber-500" />
|
| 120 |
+
Potential Duplicate Detected
|
| 121 |
+
</h3>
|
| 122 |
+
<p className="text-sm text-gray-600 mt-1">
|
| 123 |
+
A similar issue was recently reported by your teammate.
|
| 124 |
+
</p>
|
| 125 |
+
</div>
|
| 126 |
+
{onDismiss && (
|
| 127 |
+
<button
|
| 128 |
+
onClick={onDismiss}
|
| 129 |
+
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors flex-shrink-0"
|
| 130 |
+
>
|
| 131 |
+
<X size={16} />
|
| 132 |
+
</button>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* Match Info Card */}
|
| 137 |
+
<div
|
| 138 |
+
className="mt-4 rounded-xl p-4 border"
|
| 139 |
+
style={{ background: '#FFFFFF', borderColor: '#FEE2E2' }}
|
| 140 |
+
>
|
| 141 |
+
<div className="flex items-center justify-between">
|
| 142 |
+
<div className="flex items-center gap-3 min-w-0 flex-1">
|
| 143 |
+
{/* Similarity Badge */}
|
| 144 |
+
<div
|
| 145 |
+
className="flex flex-col items-center px-3 py-2 rounded-lg"
|
| 146 |
+
style={{ background: similarity >= 0.85 ? '#FEF2F2' : '#FFFBEB' }}
|
| 147 |
+
>
|
| 148 |
+
<span
|
| 149 |
+
className="text-lg font-extrabold"
|
| 150 |
+
style={{ color: sim.color }}
|
| 151 |
+
>
|
| 152 |
+
{sim.value}
|
| 153 |
+
</span>
|
| 154 |
+
<span className="text-[9px] font-semibold text-gray-400 uppercase tracking-wider">
|
| 155 |
+
Match
|
| 156 |
+
</span>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
{/* Parent Ticket Info */}
|
| 160 |
+
<div className="min-w-0 flex-1">
|
| 161 |
+
{parent_subject && (
|
| 162 |
+
<p className="text-sm font-semibold text-gray-800 truncate">
|
| 163 |
+
{parent_subject}
|
| 164 |
+
</p>
|
| 165 |
+
)}
|
| 166 |
+
<p className="text-xs text-gray-500 mt-0.5">
|
| 167 |
+
Ticket #{((duplicate_ticket_id || '') + '').slice(0, 8).toUpperCase()}
|
| 168 |
+
</p>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{/* View Parent Ticket */}
|
| 173 |
+
<a
|
| 174 |
+
href={`/admin/ticket/${duplicate_ticket_id}`}
|
| 175 |
+
className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors flex-shrink-0"
|
| 176 |
+
>
|
| 177 |
+
<Eye size={14} />
|
| 178 |
+
View
|
| 179 |
+
</a>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{/* Extra candidates */}
|
| 183 |
+
{extraCandidates.length > 0 && (
|
| 184 |
+
<div className="mt-3 pt-3 border-t border-gray-100">
|
| 185 |
+
<button
|
| 186 |
+
onClick={() => setExpanded(!expanded)}
|
| 187 |
+
className="flex items-center gap-1.5 text-xs font-semibold text-gray-500 hover:text-gray-700 transition-colors"
|
| 188 |
+
>
|
| 189 |
+
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
| 190 |
+
{extraCandidates.length} more similar ticket{extraCandidates.length > 1 ? 's' : ''}
|
| 191 |
+
</button>
|
| 192 |
+
|
| 193 |
+
{expanded && (
|
| 194 |
+
<div className="mt-2 space-y-1.5">
|
| 195 |
+
{extraCandidates.map((c, idx) => (
|
| 196 |
+
<div
|
| 197 |
+
key={c.id || idx}
|
| 198 |
+
className="flex items-center justify-between p-2 rounded-lg bg-gray-50"
|
| 199 |
+
>
|
| 200 |
+
<div className="flex items-center gap-2 min-w-0">
|
| 201 |
+
<span
|
| 202 |
+
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
|
| 203 |
+
style={{
|
| 204 |
+
background: c.similarity >= 0.85 ? '#FEF2F2' : '#FEFCE8',
|
| 205 |
+
color: c.similarity >= 0.85 ? '#DC2626' : '#CA8A04',
|
| 206 |
+
}}
|
| 207 |
+
>
|
| 208 |
+
{Math.round(c.similarity * 100)}%
|
| 209 |
+
</span>
|
| 210 |
+
<span className="text-xs text-gray-700 truncate">
|
| 211 |
+
{c.subject || `#${((c.id || '') + '').slice(0, 8)}`}
|
| 212 |
+
</span>
|
| 213 |
+
</div>
|
| 214 |
+
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
| 215 |
+
{c.assigned_team || ''}
|
| 216 |
+
</span>
|
| 217 |
+
</div>
|
| 218 |
+
))}
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
)}
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
{/* Action Buttons */}
|
| 226 |
+
<div className="flex flex-wrap items-center gap-3 mt-4">
|
| 227 |
+
{onSubscribe && (
|
| 228 |
+
<button
|
| 229 |
+
onClick={onSubscribe}
|
| 230 |
+
className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold text-white rounded-xl transition-all hover:shadow-lg active:scale-[0.98]"
|
| 231 |
+
style={{ background: 'linear-gradient(135deg, #16A34A, #15803D)' }}
|
| 232 |
+
>
|
| 233 |
+
<Bell size={16} />
|
| 234 |
+
Subscribe to Existing Ticket
|
| 235 |
+
<ArrowRight size={16} />
|
| 236 |
+
</button>
|
| 237 |
+
)}
|
| 238 |
+
{onCreateAnyway && (
|
| 239 |
+
<button
|
| 240 |
+
onClick={onCreateAnyway}
|
| 241 |
+
className="flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 hover:border-gray-300 transition-all"
|
| 242 |
+
>
|
| 243 |
+
<Send size={14} />
|
| 244 |
+
Create Anyway
|
| 245 |
+
</button>
|
| 246 |
+
)}
|
| 247 |
+
<p className="text-[10px] text-gray-400 ml-auto">
|
| 248 |
+
AI-powered semantic match · all-MiniLM-L6-v2
|
| 249 |
+
</p>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
);
|
| 256 |
+
}
|
backend/main.py
CHANGED
|
@@ -12,6 +12,7 @@ import datetime
|
|
| 12 |
import traceback
|
| 13 |
import warnings
|
| 14 |
import logging
|
|
|
|
| 15 |
from contextlib import asynccontextmanager
|
| 16 |
|
| 17 |
# Suppress harmless PyTorch CPU pin_memory warning
|
|
@@ -60,13 +61,8 @@ from backend.services.onnx_service import onnx_classifier
|
|
| 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.
|
| 64 |
-
|
| 65 |
-
calculate_sla_response_at,
|
| 66 |
-
classify_sla_status,
|
| 67 |
-
load as load_sla_service,
|
| 68 |
-
run_sla_escalation_loop,
|
| 69 |
-
)
|
| 70 |
|
| 71 |
|
| 72 |
# ---------------------------------------------------------------------------
|
|
@@ -154,13 +150,13 @@ def classify_ticket_text(text: str) -> dict:
|
|
| 154 |
"category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
|
| 155 |
"auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
|
| 156 |
}
|
|
|
|
| 157 |
class TicketRequest(BaseModel):
|
| 158 |
text: str
|
| 159 |
image_base64: str = ""
|
| 160 |
image_text: str = "" # Keep for backward compatibility
|
| 161 |
user_id: str | None = None
|
| 162 |
company: str | None = None
|
| 163 |
-
company_id: str | None = None
|
| 164 |
image_url: str | None = None
|
| 165 |
confidence_threshold: float = 0.20
|
| 166 |
duplicate_sensitivity: float = 0.85
|
|
@@ -180,10 +176,6 @@ class TicketSaveRequest(BaseModel):
|
|
| 180 |
image_url: str | None = None
|
| 181 |
company: str | None = None
|
| 182 |
company_id: str | None = None
|
| 183 |
-
description_vector: list[float] | None = None
|
| 184 |
-
is_potential_duplicate: bool = False
|
| 185 |
-
parent_ticket_id: str | None = None
|
| 186 |
-
sla_response_due_at: str | None = None
|
| 187 |
sla_breach_at: str
|
| 188 |
sla_status: str | None = None
|
| 189 |
escalation_level: int = 0
|
|
@@ -199,8 +191,6 @@ class TicketSaveRequest(BaseModel):
|
|
| 199 |
class DuplicateInfo(BaseModel):
|
| 200 |
is_duplicate: bool
|
| 201 |
duplicate_ticket_id: str | None = None
|
| 202 |
-
parent_ticket_id: str | None = None
|
| 203 |
-
is_potential_duplicate: bool = False
|
| 204 |
similarity: float = 0.0
|
| 205 |
|
| 206 |
|
|
@@ -222,8 +212,6 @@ class TicketResponse(BaseModel):
|
|
| 222 |
entities: list[EntityInfo]
|
| 223 |
duplicate_ticket: DuplicateInfo
|
| 224 |
confidence: float
|
| 225 |
-
is_potential_duplicate: bool = False
|
| 226 |
-
parent_ticket_id: str | None = None
|
| 227 |
needs_review: bool = False
|
| 228 |
reasoning: str = ""
|
| 229 |
decision_factors: list[str] = []
|
|
@@ -232,7 +220,6 @@ class TicketResponse(BaseModel):
|
|
| 232 |
highlights: list[str] = []
|
| 233 |
timeline: dict = {} # Map of step_name: timestamp
|
| 234 |
env_metadata: dict = {} # IP, Hostname, Browser/OS
|
| 235 |
-
sla_breach_at: str | None = None
|
| 236 |
version: str = "2.1.0-Neural-Diagnostic"
|
| 237 |
|
| 238 |
|
|
@@ -300,6 +287,8 @@ classifier_service = ClassifierService()
|
|
| 300 |
ner_service = NERService()
|
| 301 |
duplicate_service = DuplicateService()
|
| 302 |
rag_service = RagService()
|
|
|
|
|
|
|
| 303 |
|
| 304 |
try:
|
| 305 |
from backend.services.gemini_service import GeminiService
|
|
@@ -347,45 +336,29 @@ async def lifespan(app: FastAPI):
|
|
| 347 |
else:
|
| 348 |
print("[Startup] Gemini Service: NOT LOADED (Import failed)")
|
| 349 |
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
# Strict health checks: fail loudly when core model assets are unavailable.
|
| 354 |
-
# Set ALLOW_DEGRADED_STARTUP=1 to permit degraded startup for local/dev convenience.
|
| 355 |
-
try:
|
| 356 |
-
strict_mode = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") != "1"
|
| 357 |
-
except Exception:
|
| 358 |
-
strict_mode = True
|
| 359 |
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
from backend.services.notification_routing import load as load_notification_router
|
| 372 |
-
notification_router = load_notification_router()
|
| 373 |
-
except Exception as e:
|
| 374 |
-
print(f"[WARNING] Notification router not loaded for SLA service: {e}")
|
| 375 |
-
sla_service = load_sla_service(supabase, notification_router)
|
| 376 |
-
interval = int(os.environ.get("SLA_ESCALATION_INTERVAL_SECONDS", "300"))
|
| 377 |
-
sla_task = asyncio.create_task(run_sla_escalation_loop(sla_service, interval_seconds=interval))
|
| 378 |
-
print(f"[Startup] SLA escalation loop enabled ({interval}s interval).")
|
| 379 |
-
|
| 380 |
-
yield
|
| 381 |
-
finally:
|
| 382 |
-
if sla_task:
|
| 383 |
-
sla_task.cancel()
|
| 384 |
-
try:
|
| 385 |
-
await sla_task
|
| 386 |
-
except asyncio.CancelledError:
|
| 387 |
-
pass
|
| 388 |
-
print("[Shutdown] Cleaning up ...")
|
| 389 |
|
| 390 |
|
| 391 |
# ---------------------------------------------------------------------------
|
|
@@ -514,29 +487,18 @@ async def health_check():
|
|
| 514 |
@app.get("/ready", response_model=ReadinessResponse)
|
| 515 |
async def readiness_check():
|
| 516 |
require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
|
| 517 |
-
allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
|
| 518 |
-
|
| 519 |
checks = {
|
| 520 |
"api": True,
|
| 521 |
"classifier_loaded": classifier_service._loaded,
|
| 522 |
"ner_loaded": ner_service._loaded,
|
| 523 |
-
"duplicate_index_loaded": duplicate_service.
|
| 524 |
-
"rag_loaded": rag_service.
|
| 525 |
}
|
| 526 |
if require_supabase:
|
| 527 |
checks["supabase_configured"] = supabase is not None
|
| 528 |
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
required_checks = {k: v for k, v in checks.items() if k not in ["duplicate_index_loaded", "rag_loaded"]}
|
| 532 |
-
all_required_pass = all(required_checks.values())
|
| 533 |
-
|
| 534 |
-
if all_required_pass:
|
| 535 |
-
return ReadinessResponse(status="ready", checks=checks)
|
| 536 |
-
else:
|
| 537 |
-
# Strict mode: all checks must pass
|
| 538 |
-
if all(checks.values()):
|
| 539 |
-
return ReadinessResponse(status="ready", checks=checks)
|
| 540 |
|
| 541 |
return JSONResponse(
|
| 542 |
status_code=503,
|
|
@@ -677,31 +639,6 @@ async def get_tickets(company_id: str | None = None):
|
|
| 677 |
res = query.execute()
|
| 678 |
return res.data
|
| 679 |
|
| 680 |
-
@app.get("/tickets/search")
|
| 681 |
-
async def search_tickets(q: str | None = None, company_id: str | None = None, limit: int = 50, offset: int = 0):
|
| 682 |
-
"""Search tickets using tenant-safe full-text search."""
|
| 683 |
-
if not supabase:
|
| 684 |
-
raise HTTPException(status_code=500, detail="Database connection not initialized")
|
| 685 |
-
|
| 686 |
-
if not q:
|
| 687 |
-
raise HTTPException(status_code=400, detail="Search query is required")
|
| 688 |
-
if not company_id:
|
| 689 |
-
raise HTTPException(status_code=400, detail="company_id is required for tenant-safe search")
|
| 690 |
-
|
| 691 |
-
try:
|
| 692 |
-
result = supabase.rpc(
|
| 693 |
-
"search_tickets",
|
| 694 |
-
{
|
| 695 |
-
"query_text": q,
|
| 696 |
-
"company_id": company_id,
|
| 697 |
-
"limit_rows": limit,
|
| 698 |
-
"offset_rows": offset,
|
| 699 |
-
},
|
| 700 |
-
).execute()
|
| 701 |
-
return result.data or []
|
| 702 |
-
except Exception as e:
|
| 703 |
-
raise HTTPException(status_code=500, detail=f"Search failed: {e}")
|
| 704 |
-
|
| 705 |
@app.post("/tickets/save")
|
| 706 |
async def save_ticket(request_body: TicketSaveRequest):
|
| 707 |
"""
|
|
@@ -753,38 +690,28 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 753 |
if not final_data.get("company") and profile.get("company"):
|
| 754 |
final_data["company"] = profile["company"]
|
| 755 |
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
final_data["sla_response_due_at"] = calculate_sla_response_at(priority).isoformat().replace("+00:00", "Z")
|
| 759 |
-
if not final_data.get("sla_breach_at"):
|
| 760 |
-
final_data["sla_breach_at"] = calculate_sla_breach_at(priority).isoformat().replace("+00:00", "Z")
|
| 761 |
-
final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
|
| 762 |
-
final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
|
| 763 |
-
|
| 764 |
-
logger.info(f"Tenant linkage: user_id={request_body.user_id}, company_id={final_data.get('company_id')}")
|
| 765 |
-
|
| 766 |
-
duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
|
| 767 |
-
duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85)
|
| 768 |
-
duplicate_result = {
|
| 769 |
-
"is_duplicate": False,
|
| 770 |
-
"duplicate_ticket_id": None,
|
| 771 |
-
"parent_ticket_id": None,
|
| 772 |
-
"is_potential_duplicate": False,
|
| 773 |
-
"similarity": 0.0,
|
| 774 |
-
}
|
| 775 |
|
| 776 |
-
if duplicate_text:
|
| 777 |
-
duplicate_result = detect_semantic_duplicate(
|
| 778 |
-
duplicate_text,
|
| 779 |
-
company_id=final_data.get("company_id"),
|
| 780 |
-
threshold=duplicate_threshold,
|
| 781 |
-
)
|
| 782 |
-
final_data["description_vector"] = duplicate_service.generate_embedding(duplicate_text)
|
| 783 |
-
else:
|
| 784 |
-
final_data["description_vector"] = None
|
| 785 |
|
| 786 |
-
|
| 787 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
|
| 789 |
# --- Sanitize payload to only include valid Supabase DB columns ---
|
| 790 |
# Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
|
|
@@ -815,19 +742,29 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 815 |
|
| 816 |
ticket_id = res.data[0]["id"]
|
| 817 |
|
| 818 |
-
|
| 819 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
if duplicate_text:
|
| 821 |
try:
|
|
|
|
| 822 |
duplicate_service.add_ticket(str(ticket_id), duplicate_text)
|
|
|
|
|
|
|
| 823 |
except Exception as index_error:
|
| 824 |
-
|
| 825 |
-
duplicate_index_warning = "Duplicate index update failed."
|
| 826 |
-
print(f"[WARNING] {duplicate_index_warning} ticket_id={ticket_id} error={index_error}")
|
| 827 |
-
else:
|
| 828 |
-
duplicate_indexed = False
|
| 829 |
-
duplicate_index_warning = "Duplicate index update skipped: no description or subject text was provided."
|
| 830 |
-
print(f"[WARNING] {duplicate_index_warning}")
|
| 831 |
|
| 832 |
# Add initial system diagnostic message
|
| 833 |
msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
|
|
@@ -845,12 +782,14 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 845 |
response = {
|
| 846 |
"status": "success",
|
| 847 |
"ticket_id": ticket_id,
|
| 848 |
-
"duplicate_indexed":
|
| 849 |
-
"is_potential_duplicate": final_data["is_potential_duplicate"],
|
| 850 |
-
"parent_ticket_id": final_data["parent_ticket_id"],
|
| 851 |
}
|
| 852 |
-
if
|
| 853 |
-
response["
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
return response
|
| 855 |
|
| 856 |
except Exception as e:
|
|
@@ -920,11 +859,6 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
|
|
| 920 |
Main endpoint for analyzing a new ticket using the cascade of local AI models.
|
| 921 |
"""
|
| 922 |
text = request_body.text
|
| 923 |
-
|
| 924 |
-
settings = get_system_settings(request_body.company_id)
|
| 925 |
-
confidence_threshold = settings["ai_confidence_threshold"]
|
| 926 |
-
duplicate_sensitivity = settings["duplicate_sensitivity"]
|
| 927 |
-
enable_auto_resolve = settings["enable_auto_resolve"]
|
| 928 |
|
| 929 |
# Grab client metadata
|
| 930 |
client_ip = request.client.host if request.client else "unknown"
|
|
@@ -1047,21 +981,10 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1047 |
timeline["metadata_harvested"] = get_now_ist()
|
| 1048 |
|
| 1049 |
# --- Duplicate detection ---
|
| 1050 |
-
duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
|
| 1051 |
try:
|
| 1052 |
-
dup_result =
|
| 1053 |
-
text,
|
| 1054 |
-
company_id=request_body.company_id,
|
| 1055 |
-
threshold=duplicate_threshold,
|
| 1056 |
-
)
|
| 1057 |
except Exception:
|
| 1058 |
-
dup_result = {
|
| 1059 |
-
"is_duplicate": False,
|
| 1060 |
-
"duplicate_ticket_id": None,
|
| 1061 |
-
"parent_ticket_id": None,
|
| 1062 |
-
"is_potential_duplicate": False,
|
| 1063 |
-
"similarity": 0.0,
|
| 1064 |
-
}
|
| 1065 |
|
| 1066 |
# --- RAG Knowledge Base Check ---
|
| 1067 |
rag_match = None
|
|
@@ -1077,7 +1000,7 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1077 |
|
| 1078 |
# --- Reasoning ---
|
| 1079 |
decision_factors = []
|
| 1080 |
-
if classification["confidence"] > confidence_threshold:
|
| 1081 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1082 |
if entities:
|
| 1083 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
@@ -1087,14 +1010,6 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1087 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1088 |
|
| 1089 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1090 |
-
if (
|
| 1091 |
-
enable_auto_resolve
|
| 1092 |
-
and classification["confidence"] >= confidence_threshold
|
| 1093 |
-
and classification["auto_resolve"]
|
| 1094 |
-
):
|
| 1095 |
-
classification["auto_resolve"] = True
|
| 1096 |
-
else:
|
| 1097 |
-
classification["auto_resolve"] = False
|
| 1098 |
if classification["auto_resolve"]:
|
| 1099 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
| 1100 |
|
|
@@ -1104,8 +1019,10 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1104 |
if gemini_service and gemini_service._initialized:
|
| 1105 |
summary = gemini_service.get_summary(text)
|
| 1106 |
|
| 1107 |
-
# Convert priority to
|
| 1108 |
-
|
|
|
|
|
|
|
| 1109 |
|
| 1110 |
return TicketResponse(
|
| 1111 |
ticket_id=str(uuid.uuid4()), # Temporary ID
|
|
@@ -1118,7 +1035,7 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1118 |
entities=[EntityInfo(**e) for e in entities],
|
| 1119 |
duplicate_ticket=DuplicateInfo(**dup_result),
|
| 1120 |
confidence=classification["confidence"],
|
| 1121 |
-
needs_review=classification["confidence"] <
|
| 1122 |
reasoning=reasoning,
|
| 1123 |
decision_factors=decision_factors,
|
| 1124 |
image_description=gemini_analysis["image_description"],
|
|
@@ -1126,9 +1043,7 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1126 |
highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
|
| 1127 |
timeline=timeline,
|
| 1128 |
env_metadata=env_metadata,
|
| 1129 |
-
|
| 1130 |
-
parent_ticket_id=dup_result.get("parent_ticket_id"),
|
| 1131 |
-
sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
|
| 1132 |
)
|
| 1133 |
|
| 1134 |
@app.post("/ai/analyze_stream")
|
|
@@ -1147,11 +1062,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1147 |
"model_version": "3.0.0-PRO",
|
| 1148 |
"api_endpoint": "/ai/analyze_stream"
|
| 1149 |
}
|
| 1150 |
-
timeline = {"received": get_now_ist()}
|
| 1151 |
-
settings = get_system_settings(request_body.company_id)
|
| 1152 |
-
confidence_threshold = settings["ai_confidence_threshold"]
|
| 1153 |
-
duplicate_sensitivity = settings["duplicate_sensitivity"]
|
| 1154 |
-
enable_auto_resolve = settings["enable_auto_resolve"]
|
| 1155 |
|
| 1156 |
# 1. Reading
|
| 1157 |
yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
|
|
@@ -1187,20 +1098,9 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1187 |
yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
|
| 1188 |
await asyncio.sleep(0.2)
|
| 1189 |
try:
|
| 1190 |
-
|
| 1191 |
-
dup_result = detect_semantic_duplicate(
|
| 1192 |
-
text,
|
| 1193 |
-
company_id=request_body.company_id,
|
| 1194 |
-
threshold=duplicate_threshold,
|
| 1195 |
-
)
|
| 1196 |
except Exception:
|
| 1197 |
-
dup_result = {
|
| 1198 |
-
"is_duplicate": False,
|
| 1199 |
-
"duplicate_ticket_id": None,
|
| 1200 |
-
"parent_ticket_id": None,
|
| 1201 |
-
"is_potential_duplicate": False,
|
| 1202 |
-
"similarity": 0.0,
|
| 1203 |
-
}
|
| 1204 |
|
| 1205 |
# 5. RAG / Solutions
|
| 1206 |
yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
|
|
@@ -1216,7 +1116,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1216 |
pass
|
| 1217 |
|
| 1218 |
decision_factors = []
|
| 1219 |
-
if classification["confidence"] > confidence_threshold:
|
| 1220 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1221 |
if entities:
|
| 1222 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
@@ -1225,8 +1125,6 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1225 |
if rag_match:
|
| 1226 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1227 |
|
| 1228 |
-
if not enable_auto_resolve:
|
| 1229 |
-
classification["auto_resolve"] = False
|
| 1230 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1231 |
if classification["auto_resolve"]:
|
| 1232 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
|
@@ -1236,7 +1134,9 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1236 |
if gemini_service and gemini_service._initialized:
|
| 1237 |
summary = gemini_service.get_summary(text)
|
| 1238 |
|
| 1239 |
-
|
|
|
|
|
|
|
| 1240 |
|
| 1241 |
ticket_response_dict = {
|
| 1242 |
"ticket_id": str(uuid.uuid4()),
|
|
@@ -1249,7 +1149,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1249 |
"entities": [e for e in entities],
|
| 1250 |
"duplicate_ticket": dup_result,
|
| 1251 |
"confidence": classification["confidence"],
|
| 1252 |
-
"needs_review": classification["confidence"] <
|
| 1253 |
"reasoning": reasoning,
|
| 1254 |
"decision_factors": decision_factors,
|
| 1255 |
"image_description": gemini_analysis["image_description"],
|
|
@@ -1257,9 +1157,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1257 |
"highlights": [e.get("text", "") for e in entities],
|
| 1258 |
"timeline": timeline,
|
| 1259 |
"env_metadata": env_metadata,
|
| 1260 |
-
"
|
| 1261 |
-
"parent_ticket_id": dup_result.get("parent_ticket_id"),
|
| 1262 |
-
"sla_breach_at": sla_breach_dt.isoformat().replace("+00:00", "Z")
|
| 1263 |
}
|
| 1264 |
|
| 1265 |
# 6. Final Result
|
|
@@ -1267,7 +1165,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1267 |
|
| 1268 |
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 1269 |
|
| 1270 |
-
@app.post("/ai/analyze_ticket
|
| 1271 |
async def legacy_analyze_and_save(request_body: TicketRequest):
|
| 1272 |
"""
|
| 1273 |
BACKWARD COMPATIBILITY: Strictly performs analysis only.
|
|
@@ -1291,3 +1189,258 @@ async def analyze_ticket_v2(request: TicketRequest):
|
|
| 1291 |
}
|
| 1292 |
except Exception as e:
|
| 1293 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
import traceback
|
| 13 |
import warnings
|
| 14 |
import logging
|
| 15 |
+
import hashlib
|
| 16 |
from contextlib import asynccontextmanager
|
| 17 |
|
| 18 |
# Suppress harmless PyTorch CPU pin_memory warning
|
|
|
|
| 61 |
from backend.services.ner_service import NERService
|
| 62 |
from backend.services.duplicate_service import DuplicateService
|
| 63 |
from backend.services.rag_service import RagService
|
| 64 |
+
from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
|
| 65 |
+
from backend.services.semantic_duplicate_service import SemanticDuplicateService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
# ---------------------------------------------------------------------------
|
|
|
|
| 150 |
"category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
|
| 151 |
"auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
|
| 152 |
}
|
| 153 |
+
|
| 154 |
class TicketRequest(BaseModel):
|
| 155 |
text: str
|
| 156 |
image_base64: str = ""
|
| 157 |
image_text: str = "" # Keep for backward compatibility
|
| 158 |
user_id: str | None = None
|
| 159 |
company: str | None = None
|
|
|
|
| 160 |
image_url: str | None = None
|
| 161 |
confidence_threshold: float = 0.20
|
| 162 |
duplicate_sensitivity: float = 0.85
|
|
|
|
| 176 |
image_url: str | None = None
|
| 177 |
company: str | None = None
|
| 178 |
company_id: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
sla_breach_at: str
|
| 180 |
sla_status: str | None = None
|
| 181 |
escalation_level: int = 0
|
|
|
|
| 191 |
class DuplicateInfo(BaseModel):
|
| 192 |
is_duplicate: bool
|
| 193 |
duplicate_ticket_id: str | None = None
|
|
|
|
|
|
|
| 194 |
similarity: float = 0.0
|
| 195 |
|
| 196 |
|
|
|
|
| 212 |
entities: list[EntityInfo]
|
| 213 |
duplicate_ticket: DuplicateInfo
|
| 214 |
confidence: float
|
|
|
|
|
|
|
| 215 |
needs_review: bool = False
|
| 216 |
reasoning: str = ""
|
| 217 |
decision_factors: list[str] = []
|
|
|
|
| 220 |
highlights: list[str] = []
|
| 221 |
timeline: dict = {} # Map of step_name: timestamp
|
| 222 |
env_metadata: dict = {} # IP, Hostname, Browser/OS
|
|
|
|
| 223 |
version: str = "2.1.0-Neural-Diagnostic"
|
| 224 |
|
| 225 |
|
|
|
|
| 287 |
ner_service = NERService()
|
| 288 |
duplicate_service = DuplicateService()
|
| 289 |
rag_service = RagService()
|
| 290 |
+
sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
|
| 291 |
+
semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
|
| 292 |
|
| 293 |
try:
|
| 294 |
from backend.services.gemini_service import GeminiService
|
|
|
|
| 336 |
else:
|
| 337 |
print("[Startup] Gemini Service: NOT LOADED (Import failed)")
|
| 338 |
|
| 339 |
+
# Wire services with supabase client
|
| 340 |
+
sla_engine.supabase = supabase
|
| 341 |
+
semantic_dupe_service.supabase = supabase
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
+
# Pre-load embedding model so first ticket save is fast
|
| 344 |
+
try:
|
| 345 |
+
semantic_dupe_service.load()
|
| 346 |
+
print(f"[Startup] Semantic Duplicate Detection: {'Loaded' if semantic_dupe_service._loaded else 'Failed (model missing)'}")
|
| 347 |
+
except Exception as e:
|
| 348 |
+
print(f"[Startup] Semantic Duplicate Detection load error: {e}")
|
| 349 |
+
print(f"[Startup] SLA Engine: {'Initialized' if supabase else 'Offline (no DB)'}")
|
| 350 |
|
| 351 |
+
# Start background SLA checker as an async task (every 5 minutes)
|
| 352 |
+
if supabase:
|
| 353 |
+
from backend.sla_checker import sla_checker_loop_async
|
| 354 |
+
asyncio.create_task(sla_checker_loop_async(supabase, interval_seconds=300))
|
| 355 |
+
print("[Startup] SLA background checker started (interval=300s)")
|
| 356 |
|
| 357 |
+
print("[Startup] Classifier V2 Shadow: Ready.")
|
| 358 |
+
print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
|
| 359 |
+
print("[Startup] Ready.")
|
| 360 |
+
yield
|
| 361 |
+
print("[Shutdown] Cleaning up ...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
|
| 364 |
# ---------------------------------------------------------------------------
|
|
|
|
| 487 |
@app.get("/ready", response_model=ReadinessResponse)
|
| 488 |
async def readiness_check():
|
| 489 |
require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
|
|
|
|
|
|
|
| 490 |
checks = {
|
| 491 |
"api": True,
|
| 492 |
"classifier_loaded": classifier_service._loaded,
|
| 493 |
"ner_loaded": ner_service._loaded,
|
| 494 |
+
"duplicate_index_loaded": duplicate_service._loaded,
|
| 495 |
+
"rag_loaded": rag_service._loaded,
|
| 496 |
}
|
| 497 |
if require_supabase:
|
| 498 |
checks["supabase_configured"] = supabase is not None
|
| 499 |
|
| 500 |
+
if all(checks.values()):
|
| 501 |
+
return ReadinessResponse(status="ready", checks=checks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
|
| 503 |
return JSONResponse(
|
| 504 |
status_code=503,
|
|
|
|
| 639 |
res = query.execute()
|
| 640 |
return res.data
|
| 641 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
@app.post("/tickets/save")
|
| 643 |
async def save_ticket(request_body: TicketSaveRequest):
|
| 644 |
"""
|
|
|
|
| 690 |
if not final_data.get("company") and profile.get("company"):
|
| 691 |
final_data["company"] = profile["company"]
|
| 692 |
|
| 693 |
+
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 694 |
+
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
|
| 697 |
+
# Semantic duplicate check BEFORE inserting the ticket
|
| 698 |
+
# This allows us to warn the user before confirming
|
| 699 |
+
duplicate_check_result = None
|
| 700 |
+
try:
|
| 701 |
+
dupe_text = (request_body.description or request_body.subject or "").strip()
|
| 702 |
+
if dupe_text:
|
| 703 |
+
duplicate_check_result = await semantic_dupe_service.check_duplicate(
|
| 704 |
+
text=dupe_text,
|
| 705 |
+
company_id=final_data.get("company_id"),
|
| 706 |
+
)
|
| 707 |
+
if duplicate_check_result["is_duplicate"]:
|
| 708 |
+
logger.info(
|
| 709 |
+
f"[DUPLICATE] Ticket flagged as potential duplicate of "
|
| 710 |
+
f"{duplicate_check_result['duplicate_ticket_id']} "
|
| 711 |
+
f"(similarity: {duplicate_check_result['similarity']})"
|
| 712 |
+
)
|
| 713 |
+
except Exception as e:
|
| 714 |
+
logger.warning(f"[DUPLICATE] Semantic check error (non-fatal): {e}")
|
| 715 |
|
| 716 |
# --- Sanitize payload to only include valid Supabase DB columns ---
|
| 717 |
# Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
|
|
|
|
| 742 |
|
| 743 |
ticket_id = res.data[0]["id"]
|
| 744 |
|
| 745 |
+
# If duplicate detected, link parent ticket
|
| 746 |
+
if duplicate_check_result and duplicate_check_result["is_duplicate"]:
|
| 747 |
+
try:
|
| 748 |
+
supabase.table("tickets").update({
|
| 749 |
+
"is_potential_duplicate": True,
|
| 750 |
+
"parent_ticket_id": duplicate_check_result["duplicate_ticket_id"],
|
| 751 |
+
}).eq("id", ticket_id).execute()
|
| 752 |
+
except Exception as e:
|
| 753 |
+
logger.warning(f"[DUPLICATE] Failed to link parent ticket: {e}")
|
| 754 |
+
|
| 755 |
+
# Index the new ticket's embedding for future duplicate checks
|
| 756 |
+
embedding_indexed = False
|
| 757 |
+
description_text = (request_body.description or "").strip()
|
| 758 |
+
subject_text = (request_body.subject or "").strip()
|
| 759 |
+
duplicate_text = description_text or subject_text
|
| 760 |
if duplicate_text:
|
| 761 |
try:
|
| 762 |
+
# Both: old in-memory index (for backward compat) and new pgvector index
|
| 763 |
duplicate_service.add_ticket(str(ticket_id), duplicate_text)
|
| 764 |
+
asyncio.create_task(semantic_dupe_service.index_ticket(ticket_id, duplicate_text))
|
| 765 |
+
embedding_indexed = True
|
| 766 |
except Exception as index_error:
|
| 767 |
+
logger.warning(f"[INDEX] Failed to index ticket {ticket_id}: {index_error}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
|
| 769 |
# Add initial system diagnostic message
|
| 770 |
msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
|
|
|
|
| 782 |
response = {
|
| 783 |
"status": "success",
|
| 784 |
"ticket_id": ticket_id,
|
| 785 |
+
"duplicate_indexed": embedding_indexed,
|
|
|
|
|
|
|
| 786 |
}
|
| 787 |
+
if duplicate_check_result and duplicate_check_result["is_duplicate"]:
|
| 788 |
+
response["duplicate_warning"] = True
|
| 789 |
+
response["parent_ticket_id"] = duplicate_check_result["duplicate_ticket_id"]
|
| 790 |
+
response["parent_subject"] = duplicate_check_result.get("parent_subject")
|
| 791 |
+
response["similarity"] = duplicate_check_result["similarity"]
|
| 792 |
+
response["candidates"] = duplicate_check_result.get("candidates", [])
|
| 793 |
return response
|
| 794 |
|
| 795 |
except Exception as e:
|
|
|
|
| 859 |
Main endpoint for analyzing a new ticket using the cascade of local AI models.
|
| 860 |
"""
|
| 861 |
text = request_body.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
|
| 863 |
# Grab client metadata
|
| 864 |
client_ip = request.client.host if request.client else "unknown"
|
|
|
|
| 981 |
timeline["metadata_harvested"] = get_now_ist()
|
| 982 |
|
| 983 |
# --- Duplicate detection ---
|
|
|
|
| 984 |
try:
|
| 985 |
+
dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 986 |
except Exception:
|
| 987 |
+
dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
|
| 989 |
# --- RAG Knowledge Base Check ---
|
| 990 |
rag_match = None
|
|
|
|
| 1000 |
|
| 1001 |
# --- Reasoning ---
|
| 1002 |
decision_factors = []
|
| 1003 |
+
if classification["confidence"] > request_body.confidence_threshold:
|
| 1004 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1005 |
if entities:
|
| 1006 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
|
|
| 1010 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1011 |
|
| 1012 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1013 |
if classification["auto_resolve"]:
|
| 1014 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
| 1015 |
|
|
|
|
| 1019 |
if gemini_service and gemini_service._initialized:
|
| 1020 |
summary = gemini_service.get_summary(text)
|
| 1021 |
|
| 1022 |
+
# Convert priority to SLA breached timestamp (for preview)
|
| 1023 |
+
hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
|
| 1024 |
+
sla_hours = hours_map.get(classification["priority"], 72)
|
| 1025 |
+
sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
|
| 1026 |
|
| 1027 |
return TicketResponse(
|
| 1028 |
ticket_id=str(uuid.uuid4()), # Temporary ID
|
|
|
|
| 1035 |
entities=[EntityInfo(**e) for e in entities],
|
| 1036 |
duplicate_ticket=DuplicateInfo(**dup_result),
|
| 1037 |
confidence=classification["confidence"],
|
| 1038 |
+
needs_review=classification["confidence"] < 0.20,
|
| 1039 |
reasoning=reasoning,
|
| 1040 |
decision_factors=decision_factors,
|
| 1041 |
image_description=gemini_analysis["image_description"],
|
|
|
|
| 1043 |
highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
|
| 1044 |
timeline=timeline,
|
| 1045 |
env_metadata=env_metadata,
|
| 1046 |
+
sla_breach_at=sla_breach_dt.isoformat() + "Z"
|
|
|
|
|
|
|
| 1047 |
)
|
| 1048 |
|
| 1049 |
@app.post("/ai/analyze_stream")
|
|
|
|
| 1062 |
"model_version": "3.0.0-PRO",
|
| 1063 |
"api_endpoint": "/ai/analyze_stream"
|
| 1064 |
}
|
| 1065 |
+
timeline = {"received": get_now_ist()}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1066 |
|
| 1067 |
# 1. Reading
|
| 1068 |
yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
|
|
|
|
| 1098 |
yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
|
| 1099 |
await asyncio.sleep(0.2)
|
| 1100 |
try:
|
| 1101 |
+
dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1102 |
except Exception:
|
| 1103 |
+
dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1104 |
|
| 1105 |
# 5. RAG / Solutions
|
| 1106 |
yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
|
|
|
|
| 1116 |
pass
|
| 1117 |
|
| 1118 |
decision_factors = []
|
| 1119 |
+
if classification["confidence"] > request_body.confidence_threshold:
|
| 1120 |
decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
|
| 1121 |
if entities:
|
| 1122 |
decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
|
|
|
|
| 1125 |
if rag_match:
|
| 1126 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1127 |
|
|
|
|
|
|
|
| 1128 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1129 |
if classification["auto_resolve"]:
|
| 1130 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
|
|
|
| 1134 |
if gemini_service and gemini_service._initialized:
|
| 1135 |
summary = gemini_service.get_summary(text)
|
| 1136 |
|
| 1137 |
+
hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
|
| 1138 |
+
sla_hours = hours_map.get(classification["priority"], 72)
|
| 1139 |
+
sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
|
| 1140 |
|
| 1141 |
ticket_response_dict = {
|
| 1142 |
"ticket_id": str(uuid.uuid4()),
|
|
|
|
| 1149 |
"entities": [e for e in entities],
|
| 1150 |
"duplicate_ticket": dup_result,
|
| 1151 |
"confidence": classification["confidence"],
|
| 1152 |
+
"needs_review": classification["confidence"] < 0.20,
|
| 1153 |
"reasoning": reasoning,
|
| 1154 |
"decision_factors": decision_factors,
|
| 1155 |
"image_description": gemini_analysis["image_description"],
|
|
|
|
| 1157 |
"highlights": [e.get("text", "") for e in entities],
|
| 1158 |
"timeline": timeline,
|
| 1159 |
"env_metadata": env_metadata,
|
| 1160 |
+
"sla_breach_at": sla_breach_dt.isoformat() + "Z"
|
|
|
|
|
|
|
| 1161 |
}
|
| 1162 |
|
| 1163 |
# 6. Final Result
|
|
|
|
| 1165 |
|
| 1166 |
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 1167 |
|
| 1168 |
+
@app.post("/ai/analyze_ticket")
|
| 1169 |
async def legacy_analyze_and_save(request_body: TicketRequest):
|
| 1170 |
"""
|
| 1171 |
BACKWARD COMPATIBILITY: Strictly performs analysis only.
|
|
|
|
| 1189 |
}
|
| 1190 |
except Exception as e:
|
| 1191 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1192 |
+
|
| 1193 |
+
|
| 1194 |
+
# ---------------------------------------------------------------------------
|
| 1195 |
+
# SLA Engine Endpoints
|
| 1196 |
+
# ---------------------------------------------------------------------------
|
| 1197 |
+
|
| 1198 |
+
class SLAStatsResponse(BaseModel):
|
| 1199 |
+
total: int = 0
|
| 1200 |
+
active: int = 0
|
| 1201 |
+
breached: int = 0
|
| 1202 |
+
warning: int = 0
|
| 1203 |
+
met: int = 0
|
| 1204 |
+
breach_rate: float = 0.0
|
| 1205 |
+
by_priority: dict = {}
|
| 1206 |
+
|
| 1207 |
+
|
| 1208 |
+
@app.get("/sla/stats", response_model=SLAStatsResponse)
|
| 1209 |
+
async def sla_stats():
|
| 1210 |
+
"""Get aggregated SLA dashboard statistics across all tickets."""
|
| 1211 |
+
if not supabase:
|
| 1212 |
+
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1213 |
+
stats = await sla_engine.get_dashboard_stats()
|
| 1214 |
+
if "error" in stats:
|
| 1215 |
+
raise HTTPException(status_code=500, detail=stats["error"])
|
| 1216 |
+
return stats
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
class SLATicketInfo(BaseModel):
|
| 1220 |
+
id: str
|
| 1221 |
+
ticket_id: str | None = None
|
| 1222 |
+
subject: str | None = None
|
| 1223 |
+
summary: str | None = None
|
| 1224 |
+
priority: str = "medium"
|
| 1225 |
+
status: str | None = None
|
| 1226 |
+
assigned_team: str | None = None
|
| 1227 |
+
sla_status: str = "active"
|
| 1228 |
+
escalation_level: int = 0
|
| 1229 |
+
remaining_seconds: int = 0
|
| 1230 |
+
created_at: str | None = None
|
| 1231 |
+
sla_breach_at: str | None = None
|
| 1232 |
+
sla_warning_at: str | None = None
|
| 1233 |
+
last_escalated_at: str | None = None
|
| 1234 |
+
|
| 1235 |
+
|
| 1236 |
+
@app.get("/sla/tickets")
|
| 1237 |
+
async def sla_tickets(
|
| 1238 |
+
status: str | None = None,
|
| 1239 |
+
priority: str | None = None,
|
| 1240 |
+
limit: int = 100,
|
| 1241 |
+
offset: int = 0,
|
| 1242 |
+
):
|
| 1243 |
+
"""
|
| 1244 |
+
List tickets with SLA status. Filter by sla_status and/or priority.
|
| 1245 |
+
"""
|
| 1246 |
+
if not supabase:
|
| 1247 |
+
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1248 |
+
|
| 1249 |
+
query = (
|
| 1250 |
+
supabase.table("tickets")
|
| 1251 |
+
.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")
|
| 1252 |
+
.order("created_at", desc=True)
|
| 1253 |
+
)
|
| 1254 |
+
|
| 1255 |
+
if status and status != "all":
|
| 1256 |
+
query = query.eq("sla_status", status)
|
| 1257 |
+
if priority and priority != "all":
|
| 1258 |
+
query = query.eq("priority", priority.capitalize())
|
| 1259 |
+
|
| 1260 |
+
query = query.range(offset, offset + limit - 1)
|
| 1261 |
+
res = query.execute()
|
| 1262 |
+
return {"tickets": res.data or [], "total": len(res.data or [])}
|
| 1263 |
+
|
| 1264 |
+
|
| 1265 |
+
class EscalationLogEntry(BaseModel):
|
| 1266 |
+
id: str
|
| 1267 |
+
ticket_id: str | None = None
|
| 1268 |
+
ticket_subject: str = ""
|
| 1269 |
+
priority: str = "medium"
|
| 1270 |
+
sla_status: str = ""
|
| 1271 |
+
escalation_level: int = 0
|
| 1272 |
+
remaining_seconds: int = 0
|
| 1273 |
+
assigned_team: str = ""
|
| 1274 |
+
notification_channels: list = []
|
| 1275 |
+
triggered_at: str | None = None
|
| 1276 |
+
resolved_at: str | None = None
|
| 1277 |
+
notes: str = ""
|
| 1278 |
+
|
| 1279 |
+
|
| 1280 |
+
@app.get("/sla/escalations")
|
| 1281 |
+
async def sla_escalations(limit: int = 50, offset: int = 0):
|
| 1282 |
+
"""Fetch escalation log history."""
|
| 1283 |
+
if not supabase:
|
| 1284 |
+
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1285 |
+
|
| 1286 |
+
try:
|
| 1287 |
+
res = (
|
| 1288 |
+
supabase.table("escalation_logs")
|
| 1289 |
+
.select("*")
|
| 1290 |
+
.order("triggered_at", desc=True)
|
| 1291 |
+
.range(offset, offset + limit - 1)
|
| 1292 |
+
.execute()
|
| 1293 |
+
)
|
| 1294 |
+
return {"escalations": res.data or [], "total": len(res.data or [])}
|
| 1295 |
+
except Exception as e:
|
| 1296 |
+
# Table might not exist yet
|
| 1297 |
+
print(f"[SLA] Escalation logs query failed: {e}")
|
| 1298 |
+
return {"escalations": [], "total": 0}
|
| 1299 |
+
|
| 1300 |
+
|
| 1301 |
+
class SLAPolicyInfo(BaseModel):
|
| 1302 |
+
id: str
|
| 1303 |
+
priority: str
|
| 1304 |
+
max_hours: int
|
| 1305 |
+
warning_pct: float
|
| 1306 |
+
auto_escalate: bool
|
| 1307 |
+
l2_after_minutes: int
|
| 1308 |
+
l3_after_minutes: int
|
| 1309 |
+
|
| 1310 |
+
|
| 1311 |
+
@app.get("/sla/policies")
|
| 1312 |
+
async def sla_policies():
|
| 1313 |
+
"""Get configured SLA policies."""
|
| 1314 |
+
if not supabase:
|
| 1315 |
+
# Return defaults from code
|
| 1316 |
+
policies = []
|
| 1317 |
+
for pri, cfg in sla_engine.SLA_POLICIES.items() if hasattr(sla_engine, 'SLA_POLICIES') else SLA_POLICIES.items():
|
| 1318 |
+
policies.append({
|
| 1319 |
+
"priority": pri,
|
| 1320 |
+
"max_hours": cfg["max_hours"],
|
| 1321 |
+
"warning_pct": cfg["warning_pct"],
|
| 1322 |
+
"auto_escalate": cfg["auto_escalate_on_breach"],
|
| 1323 |
+
"l2_after_minutes": cfg["l2_escalation_mins"],
|
| 1324 |
+
"l3_after_minutes": cfg["l3_escalation_mins"],
|
| 1325 |
+
})
|
| 1326 |
+
return {"policies": policies}
|
| 1327 |
+
|
| 1328 |
+
try:
|
| 1329 |
+
res = supabase.table("sla_policies").select("*").execute()
|
| 1330 |
+
return {"policies": res.data or []}
|
| 1331 |
+
except Exception as e:
|
| 1332 |
+
print(f"[SLA] Policies query failed: {e}")
|
| 1333 |
+
return {"policies": []}
|
| 1334 |
+
|
| 1335 |
+
|
| 1336 |
+
@app.post("/sla/check")
|
| 1337 |
+
async def trigger_sla_check():
|
| 1338 |
+
"""Manually trigger an SLA evaluation cycle (admin)."""
|
| 1339 |
+
if not supabase:
|
| 1340 |
+
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1341 |
+
|
| 1342 |
+
asyncio.create_task(sla_engine.check_all_active_tickets())
|
| 1343 |
+
return {"status": "triggered", "message": "SLA check cycle started in background"}
|
| 1344 |
+
|
| 1345 |
+
|
| 1346 |
+
# ---------------------------------------------------------------------------
|
| 1347 |
+
# Semantic Duplicate Detection Endpoints
|
| 1348 |
+
# ---------------------------------------------------------------------------
|
| 1349 |
+
|
| 1350 |
+
@app.post("/ai/check_duplicate")
|
| 1351 |
+
async def check_duplicate_endpoint(
|
| 1352 |
+
body: TicketRequest,
|
| 1353 |
+
company_id: str | None = None,
|
| 1354 |
+
):
|
| 1355 |
+
"""
|
| 1356 |
+
Check a ticket text for potential duplicates using semantic vector search.
|
| 1357 |
+
Returns top candidates with similarity scores.
|
| 1358 |
+
"""
|
| 1359 |
+
text = (body.text or "").strip()
|
| 1360 |
+
if not text:
|
| 1361 |
+
raise HTTPException(status_code=400, detail="No text provided")
|
| 1362 |
+
|
| 1363 |
+
threshold = body.duplicate_sensitivity if hasattr(body, 'duplicate_sensitivity') else None
|
| 1364 |
+
result = await semantic_dupe_service.check_duplicate(
|
| 1365 |
+
text=text,
|
| 1366 |
+
company_id=company_id or body.company,
|
| 1367 |
+
threshold=threshold,
|
| 1368 |
+
)
|
| 1369 |
+
return result
|
| 1370 |
+
|
| 1371 |
+
|
| 1372 |
+
@app.post("/ai/reindex_embeddings")
|
| 1373 |
+
async def reindex_embeddings():
|
| 1374 |
+
"""Re-generate vector embeddings for all tickets."""
|
| 1375 |
+
result = await semantic_dupe_service.reindex_all()
|
| 1376 |
+
return result
|
| 1377 |
+
|
| 1378 |
+
|
| 1379 |
+
@app.get("/system/settings")
|
| 1380 |
+
async def get_system_settings():
|
| 1381 |
+
"""Fetch all system settings."""
|
| 1382 |
+
if not supabase:
|
| 1383 |
+
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1384 |
+
try:
|
| 1385 |
+
res = supabase.table("system_settings").select("*").execute()
|
| 1386 |
+
settings = {}
|
| 1387 |
+
for row in res.data or []:
|
| 1388 |
+
settings[row["key"]] = row["value"]
|
| 1389 |
+
return settings
|
| 1390 |
+
except Exception as e:
|
| 1391 |
+
logger.warning(f"[SETTINGS] Query failed: {e}")
|
| 1392 |
+
return {}
|
| 1393 |
+
|
| 1394 |
+
|
| 1395 |
+
@app.patch("/system/settings")
|
| 1396 |
+
async def update_system_settings(body: dict):
|
| 1397 |
+
"""Update a specific system setting."""
|
| 1398 |
+
if not supabase:
|
| 1399 |
+
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1400 |
+
key = body.get("key")
|
| 1401 |
+
value = body.get("value")
|
| 1402 |
+
if not key or value is None:
|
| 1403 |
+
raise HTTPException(status_code=400, detail="key and value required")
|
| 1404 |
+
try:
|
| 1405 |
+
supabase.table("system_settings").upsert({
|
| 1406 |
+
"key": key,
|
| 1407 |
+
"value": value,
|
| 1408 |
+
"updated_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 1409 |
+
}).execute()
|
| 1410 |
+
return {"status": "updated", "key": key}
|
| 1411 |
+
except Exception as e:
|
| 1412 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1413 |
+
|
| 1414 |
+
|
| 1415 |
+
@app.get("/sla/tickets/{ticket_id}")
|
| 1416 |
+
async def sla_ticket_detail(ticket_id: str):
|
| 1417 |
+
"""Get detailed SLA info for a specific ticket."""
|
| 1418 |
+
if not supabase:
|
| 1419 |
+
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1420 |
+
|
| 1421 |
+
# Fetch ticket
|
| 1422 |
+
res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
|
| 1423 |
+
if not res.data:
|
| 1424 |
+
raise HTTPException(status_code=404, detail="Ticket not found")
|
| 1425 |
+
|
| 1426 |
+
ticket = res.data
|
| 1427 |
+
result = sla_engine.evaluate_ticket(ticket)
|
| 1428 |
+
|
| 1429 |
+
# Fetch escalation history for this ticket
|
| 1430 |
+
try:
|
| 1431 |
+
esc_res = (
|
| 1432 |
+
supabase.table("escalation_logs")
|
| 1433 |
+
.select("*")
|
| 1434 |
+
.eq("ticket_id", ticket_id)
|
| 1435 |
+
.order("triggered_at", desc=True)
|
| 1436 |
+
.execute()
|
| 1437 |
+
)
|
| 1438 |
+
escalations = esc_res.data or []
|
| 1439 |
+
except Exception:
|
| 1440 |
+
escalations = []
|
| 1441 |
+
|
| 1442 |
+
return {
|
| 1443 |
+
"ticket": ticket,
|
| 1444 |
+
"sla_evaluation": result,
|
| 1445 |
+
"escalations": escalations,
|
| 1446 |
+
}
|
backend/services/semantic_duplicate_service.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Semantic Duplicate Detection Service — pgvector-powered cosine similarity search.
|
| 3 |
+
|
| 4 |
+
Enhances the existing in-memory DuplicateService with Supabase pgvector integration:
|
| 5 |
+
1. Store embeddings in the tickets table (description_vector column)
|
| 6 |
+
2. Use match_tickets RPC for company-scoped similarity search
|
| 7 |
+
3. Dynamic sensitivity from system_settings table
|
| 8 |
+
4. Auto-flag duplicates during ticket save flow
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
service = SemanticDuplicateService(supabase_client)
|
| 12 |
+
result = await service.check_duplicate(text, company_id, threshold_override)
|
| 13 |
+
await service.index_ticket(ticket_id, text)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
# Default threshold (0.0-1.0, higher = stricter)
|
| 24 |
+
DEFAULT_SENSITIVITY = 0.85
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SemanticDuplicateService:
|
| 28 |
+
"""
|
| 29 |
+
Duplicate detection backed by pgvector in Supabase.
|
| 30 |
+
|
| 31 |
+
Falls back to in-memory DuplicateService if the vector DB is unavailable,
|
| 32 |
+
but the primary path is via the match_tickets RPC function.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, supabase_client=None):
|
| 36 |
+
self.supabase = supabase_client
|
| 37 |
+
self._model = None
|
| 38 |
+
self._loaded = False
|
| 39 |
+
|
| 40 |
+
def load(self):
|
| 41 |
+
"""Lazy-load the sentence-transformers model (called once)."""
|
| 42 |
+
if self._loaded:
|
| 43 |
+
return
|
| 44 |
+
try:
|
| 45 |
+
from sentence_transformers import SentenceTransformer
|
| 46 |
+
self._model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 47 |
+
self._loaded = True
|
| 48 |
+
logger.info("[SemanticDuplicate] Model loaded (all-MiniLM-L6-v2)")
|
| 49 |
+
except ImportError:
|
| 50 |
+
logger.warning("[SemanticDuplicate] sentence-transformers not installed")
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"[SemanticDuplicate] Model load error: {e}")
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def model(self):
|
| 56 |
+
if not self._loaded:
|
| 57 |
+
self.load()
|
| 58 |
+
return self._model
|
| 59 |
+
|
| 60 |
+
# ------------------------------------------------------------------
|
| 61 |
+
# Embedding generation
|
| 62 |
+
# ------------------------------------------------------------------
|
| 63 |
+
|
| 64 |
+
def generate_embedding(self, text: str) -> list[float] | None:
|
| 65 |
+
"""
|
| 66 |
+
Generate a 384-dimensional embedding vector for the given text.
|
| 67 |
+
Returns None if the model isn't loaded.
|
| 68 |
+
"""
|
| 69 |
+
if not self.model:
|
| 70 |
+
return None
|
| 71 |
+
try:
|
| 72 |
+
return self.model.encode(text).tolist()
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"[SemanticDuplicate] Embedding error: {e}")
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
# ------------------------------------------------------------------
|
| 78 |
+
# Dynamic threshold from system_settings
|
| 79 |
+
# ------------------------------------------------------------------
|
| 80 |
+
|
| 81 |
+
async def get_sensitivity(self) -> float:
|
| 82 |
+
"""
|
| 83 |
+
Fetch the duplicate detection sensitivity from system_settings.
|
| 84 |
+
Falls back to DEFAULT_SENSITIVITY.
|
| 85 |
+
"""
|
| 86 |
+
if not self.supabase:
|
| 87 |
+
return DEFAULT_SENSITIVITY
|
| 88 |
+
try:
|
| 89 |
+
res = self.supabase.table("system_settings") \
|
| 90 |
+
.select("value") \
|
| 91 |
+
.eq("key", "duplicate_detection") \
|
| 92 |
+
.single() \
|
| 93 |
+
.execute()
|
| 94 |
+
if res.data:
|
| 95 |
+
return float(res.data["value"].get("sensitivity", DEFAULT_SENSITIVITY))
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.warning(f"[SemanticDuplicate] Failed to fetch sensitivity: {e}")
|
| 98 |
+
return DEFAULT_SENSITIVITY
|
| 99 |
+
|
| 100 |
+
# ------------------------------------------------------------------
|
| 101 |
+
# Duplicate check via pgvector RPC
|
| 102 |
+
# ------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
async def check_duplicate(
|
| 105 |
+
self,
|
| 106 |
+
text: str,
|
| 107 |
+
company_id: str | None = None,
|
| 108 |
+
threshold: float | None = None,
|
| 109 |
+
max_candidates: int = 5,
|
| 110 |
+
) -> dict:
|
| 111 |
+
"""
|
| 112 |
+
Check text against existing tickets using pgvector cosine similarity.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
text: The ticket description/subject text.
|
| 116 |
+
company_id: Tenant company UUID for scoped search.
|
| 117 |
+
threshold: Override threshold (0.0-1.0). Uses dynamic setting if None.
|
| 118 |
+
max_candidates: Max similar tickets to return.
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
{
|
| 122 |
+
"is_duplicate": bool,
|
| 123 |
+
"duplicate_ticket_id": str | None,
|
| 124 |
+
"similarity": float,
|
| 125 |
+
"candidates": [...] # top candidates with scores
|
| 126 |
+
}
|
| 127 |
+
"""
|
| 128 |
+
# Generate embedding
|
| 129 |
+
embedding = self.generate_embedding(text)
|
| 130 |
+
if embedding is None:
|
| 131 |
+
logger.warning("[SemanticDuplicate] No embedding — falling back to no match")
|
| 132 |
+
return {
|
| 133 |
+
"is_duplicate": False,
|
| 134 |
+
"duplicate_ticket_id": None,
|
| 135 |
+
"similarity": 0.0,
|
| 136 |
+
"candidates": [],
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
active_threshold = threshold if threshold is not None else await self.get_sensitivity()
|
| 140 |
+
|
| 141 |
+
# Perform vector search
|
| 142 |
+
if self.supabase:
|
| 143 |
+
try:
|
| 144 |
+
result = self.supabase.rpc(
|
| 145 |
+
"match_tickets",
|
| 146 |
+
{
|
| 147 |
+
"query_vector": embedding,
|
| 148 |
+
"match_threshold": active_threshold,
|
| 149 |
+
"match_count": max_candidates,
|
| 150 |
+
"tenant_company_id": company_id,
|
| 151 |
+
},
|
| 152 |
+
).execute()
|
| 153 |
+
|
| 154 |
+
candidates = result.data or []
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.warning(f"[SemanticDuplicate] RPC call failed: {e}")
|
| 157 |
+
candidates = []
|
| 158 |
+
else:
|
| 159 |
+
candidates = []
|
| 160 |
+
|
| 161 |
+
if not candidates:
|
| 162 |
+
return {
|
| 163 |
+
"is_duplicate": False,
|
| 164 |
+
"duplicate_ticket_id": None,
|
| 165 |
+
"similarity": 0.0,
|
| 166 |
+
"candidates": [],
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
best = candidates[0]
|
| 170 |
+
similarity = float(best.get("similarity", 0.0))
|
| 171 |
+
is_dup = similarity >= active_threshold
|
| 172 |
+
|
| 173 |
+
return {
|
| 174 |
+
"is_duplicate": is_dup,
|
| 175 |
+
"duplicate_ticket_id": best.get("id") if is_dup else None,
|
| 176 |
+
"similarity": round(similarity, 4),
|
| 177 |
+
"parent_subject": best.get("subject") or best.get("summary") if is_dup else None,
|
| 178 |
+
"candidates": [
|
| 179 |
+
{
|
| 180 |
+
"id": c["id"],
|
| 181 |
+
"subject": c.get("subject") or c.get("summary"),
|
| 182 |
+
"similarity": round(float(c.get("similarity", 0)), 4),
|
| 183 |
+
"status": c.get("status"),
|
| 184 |
+
"assigned_team": c.get("assigned_team"),
|
| 185 |
+
"created_at": c.get("created_at"),
|
| 186 |
+
}
|
| 187 |
+
for c in candidates[:3]
|
| 188 |
+
],
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
# ------------------------------------------------------------------
|
| 192 |
+
# Index a ticket — generate embedding and store it
|
| 193 |
+
# ------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
async def index_ticket(self, ticket_id: str, text: str) -> bool:
|
| 196 |
+
"""
|
| 197 |
+
Generate embedding for a ticket and update the description_vector column.
|
| 198 |
+
Returns True on success.
|
| 199 |
+
"""
|
| 200 |
+
embedding = self.generate_embedding(text)
|
| 201 |
+
if embedding is None:
|
| 202 |
+
logger.warning(f"[SemanticDuplicate] Cannot index ticket {ticket_id}: no embedding")
|
| 203 |
+
return False
|
| 204 |
+
|
| 205 |
+
if not self.supabase:
|
| 206 |
+
logger.warning("[SemanticDuplicate] No Supabase — skipping index")
|
| 207 |
+
return False
|
| 208 |
+
|
| 209 |
+
try:
|
| 210 |
+
self.supabase.table("tickets") \
|
| 211 |
+
.update({"description_vector": embedding}) \
|
| 212 |
+
.eq("id", ticket_id) \
|
| 213 |
+
.execute()
|
| 214 |
+
logger.info(f"[SemanticDuplicate] Indexed ticket {ticket_id} ({len(text)} chars)")
|
| 215 |
+
return True
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"[SemanticDuplicate] Index error for {ticket_id}: {e}")
|
| 218 |
+
return False
|
| 219 |
+
|
| 220 |
+
# ------------------------------------------------------------------
|
| 221 |
+
# Batch re-index — regenerate all embeddings
|
| 222 |
+
# ------------------------------------------------------------------
|
| 223 |
+
|
| 224 |
+
async def reindex_all(self, batch_size: int = 50) -> dict:
|
| 225 |
+
"""
|
| 226 |
+
Re-generate embeddings for all tickets that don't have them.
|
| 227 |
+
Useful for initial migration or after model update.
|
| 228 |
+
"""
|
| 229 |
+
if not self.supabase:
|
| 230 |
+
return {"indexed": 0, "errors": 0}
|
| 231 |
+
|
| 232 |
+
total_indexed = 0
|
| 233 |
+
total_errors = 0
|
| 234 |
+
offset = 0
|
| 235 |
+
|
| 236 |
+
while True:
|
| 237 |
+
res = self.supabase.table("tickets") \
|
| 238 |
+
.select("id, subject, description, summary") \
|
| 239 |
+
.is_("description_vector", "null") \
|
| 240 |
+
.range(offset, offset + batch_size - 1) \
|
| 241 |
+
.execute()
|
| 242 |
+
|
| 243 |
+
batch = res.data or []
|
| 244 |
+
if not batch:
|
| 245 |
+
break
|
| 246 |
+
|
| 247 |
+
for ticket in batch:
|
| 248 |
+
text = (ticket.get("description") or ticket.get("subject") or ticket.get("summary") or "").strip()
|
| 249 |
+
if not text:
|
| 250 |
+
continue
|
| 251 |
+
success = await self.index_ticket(ticket["id"], text)
|
| 252 |
+
if success:
|
| 253 |
+
total_indexed += 1
|
| 254 |
+
else:
|
| 255 |
+
total_errors += 1
|
| 256 |
+
|
| 257 |
+
offset += batch_size
|
| 258 |
+
|
| 259 |
+
logger.info(f"[SemanticDuplicate] Re-index complete: {total_indexed} indexed, {total_errors} errors")
|
| 260 |
+
return {"indexed": total_indexed, "errors": total_errors}
|
backend/services/sla_engine.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SLA Engine — Service-Level Agreement monitoring, breach detection,
|
| 3 |
+
and multi-channel escalation for HELPDESK.AI.
|
| 4 |
+
|
| 5 |
+
Architecture:
|
| 6 |
+
- SLA policies defined per priority tier
|
| 7 |
+
- Background checker evaluates ticket SLA in batches
|
| 8 |
+
- Escalation matrix drives notification routing
|
| 9 |
+
- Multi-channel dispatch (Email / Slack / Teams / Webhook)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import datetime
|
| 16 |
+
from typing import Any
|
| 17 |
+
from enum import Enum
|
| 18 |
+
from pydantic import BaseModel
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Constants
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
class SLAStatus(str, Enum):
|
| 27 |
+
ACTIVE = "active"
|
| 28 |
+
WARNING = "warning"
|
| 29 |
+
BREACHED = "breached"
|
| 30 |
+
MET = "met"
|
| 31 |
+
PAUSED = "paused" # ticket on hold
|
| 32 |
+
|
| 33 |
+
class EscalationLevel(int, Enum):
|
| 34 |
+
NONE = 0
|
| 35 |
+
LEVEL_1 = 1 # Warning → notify assigned team
|
| 36 |
+
LEVEL_2 = 2 # Breach → escalate to L2 / manager
|
| 37 |
+
LEVEL_3 = 3 # Extended breach → escalate to L3 / senior / admin
|
| 38 |
+
|
| 39 |
+
class ChannelType(str, Enum):
|
| 40 |
+
EMAIL = "email"
|
| 41 |
+
SLACK = "slack"
|
| 42 |
+
TEAMS = "teams"
|
| 43 |
+
WEBHOOK = "webhook"
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# SLA Policy definitions
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
SLAMetric = dict[str, Any] # {"max_hours": N, "warning_pct": 0.75, "l2_escalation_after_mins": M, ...}
|
| 50 |
+
|
| 51 |
+
SLA_POLICIES: dict[str, SLAMetric] = {
|
| 52 |
+
"critical": {
|
| 53 |
+
"max_hours": 2,
|
| 54 |
+
"max_seconds": 2 * 3600,
|
| 55 |
+
"warning_pct": 0.75, # warn at 75% of SLA
|
| 56 |
+
"warning_label": "1.5h remaining",
|
| 57 |
+
"l2_escalation_mins": 0, # escalate to L2 immediately on breach
|
| 58 |
+
"l3_escalation_mins": 120, # escalate to L3 after 2h of breach
|
| 59 |
+
"auto_escalate_on_breach": True,
|
| 60 |
+
},
|
| 61 |
+
"high": {
|
| 62 |
+
"max_hours": 4,
|
| 63 |
+
"max_seconds": 4 * 3600,
|
| 64 |
+
"warning_pct": 0.75,
|
| 65 |
+
"warning_label": "3h remaining",
|
| 66 |
+
"l2_escalation_mins": 30,
|
| 67 |
+
"l3_escalation_mins": 240,
|
| 68 |
+
"auto_escalate_on_breach": True,
|
| 69 |
+
},
|
| 70 |
+
"medium": {
|
| 71 |
+
"max_hours": 8,
|
| 72 |
+
"max_seconds": 8 * 3600,
|
| 73 |
+
"warning_pct": 0.75,
|
| 74 |
+
"warning_label": "6h remaining",
|
| 75 |
+
"l2_escalation_mins": 60,
|
| 76 |
+
"l3_escalation_mins": 480,
|
| 77 |
+
"auto_escalate_on_breach": True,
|
| 78 |
+
},
|
| 79 |
+
"low": {
|
| 80 |
+
"max_hours": 24,
|
| 81 |
+
"max_seconds": 24 * 3600,
|
| 82 |
+
"warning_pct": 0.75,
|
| 83 |
+
"warning_label": "18h remaining",
|
| 84 |
+
"l2_escalation_mins": 120,
|
| 85 |
+
"l3_escalation_mins": 1440,
|
| 86 |
+
"auto_escalate_on_breach": False, # low priority: no auto-escalation
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# Escalation contact mapping (configured via env / DB)
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
def _load_escalation_channels() -> list[dict]:
|
| 95 |
+
"""Load channel config from JSON file or env vars."""
|
| 96 |
+
channels_raw = os.environ.get("SLA_CHANNELS", "[]")
|
| 97 |
+
try:
|
| 98 |
+
return json.loads(channels_raw)
|
| 99 |
+
except json.JSONDecodeError:
|
| 100 |
+
return []
|
| 101 |
+
|
| 102 |
+
def _load_team_escalation_contacts() -> dict:
|
| 103 |
+
"""Load team escalation contacts."""
|
| 104 |
+
contacts_raw = os.environ.get("SLA_ESCALATION_CONTACTS", "{}")
|
| 105 |
+
try:
|
| 106 |
+
return json.loads(contacts_raw)
|
| 107 |
+
except json.JSONDecodeError:
|
| 108 |
+
return {}
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# Core SLA Engine
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
class SLAEngine:
|
| 115 |
+
"""
|
| 116 |
+
Evaluates ticket SLA status, detects breaches, and dispatches escalations.
|
| 117 |
+
|
| 118 |
+
Usage:
|
| 119 |
+
engine = SLAEngine(supabase_client)
|
| 120 |
+
await engine.check_all_active_tickets()
|
| 121 |
+
"""
|
| 122 |
+
|
| 123 |
+
def __init__(self, supabase_client=None):
|
| 124 |
+
self.supabase = supabase_client
|
| 125 |
+
self.channels = _load_escalation_channels()
|
| 126 |
+
self.contacts = _load_team_escalation_contacts()
|
| 127 |
+
|
| 128 |
+
# ------------------------------------------------------------------
|
| 129 |
+
# SLA Evaluation per ticket
|
| 130 |
+
# ------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
def evaluate_ticket(self, ticket: dict) -> dict:
|
| 133 |
+
"""
|
| 134 |
+
Evaluate a single ticket's SLA status.
|
| 135 |
+
|
| 136 |
+
Returns dict with:
|
| 137 |
+
- sla_status: SLAStatus value
|
| 138 |
+
- remaining_seconds: seconds until breach (negative if breached)
|
| 139 |
+
- elapsed_pct: percentage of SLA time elapsed
|
| 140 |
+
- escalation_level: EscalationLevel value
|
| 141 |
+
- needs_notification: bool
|
| 142 |
+
- policy: the applied SLA policy
|
| 143 |
+
"""
|
| 144 |
+
priority_key = (ticket.get("priority") or "medium").lower().strip()
|
| 145 |
+
policy = SLA_POLICIES.get(priority_key, SLA_POLICIES["medium"])
|
| 146 |
+
|
| 147 |
+
# Resolve the start time: use sla_started_at if set, else created_at
|
| 148 |
+
start_str = ticket.get("sla_started_at") or ticket.get("created_at")
|
| 149 |
+
if not start_str:
|
| 150 |
+
return self._default_eval(policy)
|
| 151 |
+
|
| 152 |
+
try:
|
| 153 |
+
created_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
| 154 |
+
except (ValueError, TypeError):
|
| 155 |
+
return self._default_eval(policy)
|
| 156 |
+
|
| 157 |
+
# If ticket is resolved / closed, SLA is "met"
|
| 158 |
+
status = (ticket.get("status") or "").lower()
|
| 159 |
+
if any(s in status for s in ["resolv", "closed", "auto-resolv"]):
|
| 160 |
+
return {
|
| 161 |
+
"sla_status": SLAStatus.MET,
|
| 162 |
+
"remaining_seconds": 0,
|
| 163 |
+
"elapsed_pct": 1.0,
|
| 164 |
+
"escalation_level": EscalationLevel.NONE,
|
| 165 |
+
"needs_notification": False,
|
| 166 |
+
"policy": policy,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
# Calculate elapsed time
|
| 170 |
+
now = datetime.datetime.now(datetime.timezone.utc)
|
| 171 |
+
elapsed = (now - created_dt).total_seconds()
|
| 172 |
+
max_sec = policy["max_seconds"]
|
| 173 |
+
elapsed_pct = elapsed / max_sec if max_sec > 0 else 0
|
| 174 |
+
|
| 175 |
+
# Determine SLA status
|
| 176 |
+
if elapsed >= max_sec:
|
| 177 |
+
sla_status = SLAStatus.BREACHED
|
| 178 |
+
remaining_seconds = -(elapsed - max_sec)
|
| 179 |
+
elif elapsed >= max_sec * policy["warning_pct"]:
|
| 180 |
+
sla_status = SLAStatus.WARNING
|
| 181 |
+
remaining_seconds = max_sec - elapsed
|
| 182 |
+
else:
|
| 183 |
+
sla_status = SLAStatus.ACTIVE
|
| 184 |
+
remaining_seconds = max_sec - elapsed
|
| 185 |
+
|
| 186 |
+
# Determine escalation level
|
| 187 |
+
escalation_level = EscalationLevel.NONE
|
| 188 |
+
if sla_status == SLAStatus.BREACHED:
|
| 189 |
+
breach_duration = elapsed - max_sec
|
| 190 |
+
if breach_duration >= policy["l3_escalation_mins"] * 60:
|
| 191 |
+
escalation_level = EscalationLevel.LEVEL_3
|
| 192 |
+
elif breach_duration >= policy["l2_escalation_mins"] * 60:
|
| 193 |
+
escalation_level = EscalationLevel.LEVEL_2
|
| 194 |
+
else:
|
| 195 |
+
escalation_level = EscalationLevel.LEVEL_2 # breach = at least L2
|
| 196 |
+
elif sla_status == SLAStatus.WARNING:
|
| 197 |
+
escalation_level = EscalationLevel.LEVEL_1
|
| 198 |
+
|
| 199 |
+
needs_notification = (
|
| 200 |
+
sla_status in (SLAStatus.WARNING, SLAStatus.BREACHED)
|
| 201 |
+
and escalation_level.value > (ticket.get("escalation_level") or 0)
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
"sla_status": sla_status.value,
|
| 206 |
+
"remaining_seconds": round(remaining_seconds),
|
| 207 |
+
"elapsed_pct": round(elapsed_pct, 4),
|
| 208 |
+
"escalation_level": escalation_level.value,
|
| 209 |
+
"needs_notification": needs_notification,
|
| 210 |
+
"policy": policy,
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
def _default_eval(self, policy: dict) -> dict:
|
| 214 |
+
return {
|
| 215 |
+
"sla_status": SLAStatus.ACTIVE.value,
|
| 216 |
+
"remaining_seconds": policy["max_seconds"],
|
| 217 |
+
"elapsed_pct": 0.0,
|
| 218 |
+
"escalation_level": 0,
|
| 219 |
+
"needs_notification": False,
|
| 220 |
+
"policy": policy,
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
# ------------------------------------------------------------------
|
| 224 |
+
# Batch check — evaluate all active tickets
|
| 225 |
+
# ------------------------------------------------------------------
|
| 226 |
+
|
| 227 |
+
async def check_all_active_tickets(self) -> list[dict]:
|
| 228 |
+
"""
|
| 229 |
+
Fetches all active (non-resolved) tickets from Supabase,
|
| 230 |
+
evaluates SLA, updates DB records, and triggers notifications.
|
| 231 |
+
|
| 232 |
+
Returns list of escalated tickets that need notification dispatch.
|
| 233 |
+
"""
|
| 234 |
+
if not self.supabase:
|
| 235 |
+
logger.warning("[SLA] No Supabase client — skipping SLA check.")
|
| 236 |
+
return []
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
res = self.supabase.table("tickets") \
|
| 240 |
+
.select("*") \
|
| 241 |
+
.not_.ilike("status", "%resolv%") \
|
| 242 |
+
.not_.ilike("status", "%closed%") \
|
| 243 |
+
.execute()
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"[SLA] Failed to fetch tickets: {e}")
|
| 246 |
+
return []
|
| 247 |
+
|
| 248 |
+
tickets = res.data or []
|
| 249 |
+
escalated: list[dict] = []
|
| 250 |
+
|
| 251 |
+
for ticket in tickets:
|
| 252 |
+
result = self.evaluate_ticket(ticket)
|
| 253 |
+
update_fields = {
|
| 254 |
+
"sla_status": result["sla_status"],
|
| 255 |
+
"remaining_seconds": result["remaining_seconds"],
|
| 256 |
+
"escalation_level": result["escalation_level"],
|
| 257 |
+
"sla_updated_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
# If breached and not yet marked as breached, set breached timestamp
|
| 261 |
+
if result["sla_status"] == SLAStatus.BREACHED.value and not ticket.get("sla_breach_at"):
|
| 262 |
+
update_fields["sla_breach_at"] = datetime.datetime.utcnow().isoformat() + "Z"
|
| 263 |
+
|
| 264 |
+
# If warning and not yet notified, mark warning time
|
| 265 |
+
if result["sla_status"] == SLAStatus.WARNING.value and not ticket.get("sla_warning_at"):
|
| 266 |
+
update_fields["sla_warning_at"] = datetime.datetime.utcnow().isoformat() + "Z"
|
| 267 |
+
|
| 268 |
+
# If needs notification, prepare escalation record
|
| 269 |
+
if result["needs_notification"]:
|
| 270 |
+
update_fields["last_escalated_at"] = datetime.datetime.utcnow().isoformat() + "Z"
|
| 271 |
+
escalated.append({
|
| 272 |
+
"ticket": ticket,
|
| 273 |
+
"sla_result": result,
|
| 274 |
+
})
|
| 275 |
+
|
| 276 |
+
# Update ticket in DB
|
| 277 |
+
try:
|
| 278 |
+
self.supabase.table("tickets") \
|
| 279 |
+
.update(update_fields) \
|
| 280 |
+
.eq("id", ticket["id"]) \
|
| 281 |
+
.execute()
|
| 282 |
+
except Exception as e:
|
| 283 |
+
logger.error(f"[SLA] Failed to update ticket {ticket.get('id')}: {e}")
|
| 284 |
+
|
| 285 |
+
# Log escalations to escalation_logs table
|
| 286 |
+
for item in escalated:
|
| 287 |
+
self._log_escalation(item["ticket"], item["sla_result"])
|
| 288 |
+
|
| 289 |
+
logger.info(
|
| 290 |
+
f"[SLA] Checked {len(tickets)} tickets "
|
| 291 |
+
f"— {len(escalated)} escalated."
|
| 292 |
+
)
|
| 293 |
+
return escalated
|
| 294 |
+
|
| 295 |
+
# ------------------------------------------------------------------
|
| 296 |
+
# Escalation logging
|
| 297 |
+
# ------------------------------------------------------------------
|
| 298 |
+
|
| 299 |
+
def _log_escalation(self, ticket: dict, result: dict):
|
| 300 |
+
"""Insert an escalation event into the escalation_logs table."""
|
| 301 |
+
if not self.supabase:
|
| 302 |
+
return
|
| 303 |
+
try:
|
| 304 |
+
self.supabase.table("escalation_logs").insert({
|
| 305 |
+
"ticket_id": ticket["id"],
|
| 306 |
+
"ticket_subject": ticket.get("subject") or ticket.get("summary", ""),
|
| 307 |
+
"priority": ticket.get("priority", "medium"),
|
| 308 |
+
"sla_status": result["sla_status"],
|
| 309 |
+
"escalation_level": result["escalation_level"],
|
| 310 |
+
"remaining_seconds": result["remaining_seconds"],
|
| 311 |
+
"assigned_team": ticket.get("assigned_team", ""),
|
| 312 |
+
"triggered_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 313 |
+
"notification_channels": [], # populated by notifier
|
| 314 |
+
}).execute()
|
| 315 |
+
except Exception as e:
|
| 316 |
+
logger.error(f"[SLA] Failed to log escalation: {e}")
|
| 317 |
+
|
| 318 |
+
# ------------------------------------------------------------------
|
| 319 |
+
# Multi-channel notification dispatch
|
| 320 |
+
# ------------------------------------------------------------------
|
| 321 |
+
|
| 322 |
+
async def dispatch_notifications(self, escalated_items: list[dict]):
|
| 323 |
+
"""
|
| 324 |
+
Dispatch SLA alerts across configured channels.
|
| 325 |
+
Each item: { ticket, sla_result }
|
| 326 |
+
"""
|
| 327 |
+
for item in escalated_items:
|
| 328 |
+
ticket = item["ticket"]
|
| 329 |
+
result = item["sla_result"]
|
| 330 |
+
channels = self._resolve_channels(ticket, result)
|
| 331 |
+
|
| 332 |
+
for channel in channels:
|
| 333 |
+
success = await self._send_to_channel(channel, ticket, result)
|
| 334 |
+
if success:
|
| 335 |
+
logger.info(
|
| 336 |
+
f"[SLA] Notification sent via {channel['type']} "
|
| 337 |
+
f"for ticket {ticket.get('id')}"
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
def _resolve_channels(self, ticket: dict, result: dict) -> list[dict]:
|
| 341 |
+
"""Determine which channels to send to based on escalation level."""
|
| 342 |
+
level = result["escalation_level"]
|
| 343 |
+
active_channels = [c for c in self.channels if c.get("enabled", True)]
|
| 344 |
+
|
| 345 |
+
# Filter by minimum escalation level
|
| 346 |
+
eligible = [
|
| 347 |
+
c for c in active_channels
|
| 348 |
+
if c.get("min_level", 0) <= level
|
| 349 |
+
]
|
| 350 |
+
|
| 351 |
+
return eligible
|
| 352 |
+
|
| 353 |
+
async def _send_to_channel(self, channel: dict, ticket: dict, result: dict) -> bool:
|
| 354 |
+
"""Send an SLA notification through a specific channel."""
|
| 355 |
+
channel_type = channel.get("type", "webhook")
|
| 356 |
+
payload = self._build_payload(ticket, result, channel_type)
|
| 357 |
+
|
| 358 |
+
try:
|
| 359 |
+
import aiohttp
|
| 360 |
+
async with aiohttp.ClientSession() as session:
|
| 361 |
+
async with session.post(
|
| 362 |
+
channel["url"],
|
| 363 |
+
json=payload,
|
| 364 |
+
headers=channel.get("headers", {"Content-Type": "application/json"}),
|
| 365 |
+
timeout=aiohttp.ClientTimeout(total=10),
|
| 366 |
+
) as resp:
|
| 367 |
+
return resp.status < 500
|
| 368 |
+
except ImportError:
|
| 369 |
+
logger.warning("[SLA] aiohttp not installed — using requests (sync).")
|
| 370 |
+
import requests
|
| 371 |
+
try:
|
| 372 |
+
resp = requests.post(
|
| 373 |
+
channel["url"],
|
| 374 |
+
json=payload,
|
| 375 |
+
headers=channel.get("headers", {"Content-Type": "application/json"}),
|
| 376 |
+
timeout=10,
|
| 377 |
+
)
|
| 378 |
+
return resp.status_code < 500
|
| 379 |
+
except Exception as e:
|
| 380 |
+
logger.error(f"[SLA] Channel send failed ({channel_type}): {e}")
|
| 381 |
+
return False
|
| 382 |
+
except Exception as e:
|
| 383 |
+
logger.error(f"[SLA] Channel send failed ({channel_type}): {e}")
|
| 384 |
+
return False
|
| 385 |
+
|
| 386 |
+
def _build_payload(self, ticket: dict, result: dict, channel_type: str) -> dict:
|
| 387 |
+
"""Build a channel-specific notification payload."""
|
| 388 |
+
priority = (ticket.get("priority") or "medium").upper()
|
| 389 |
+
ticket_id = str(ticket.get("id", "???"))[:8].upper()
|
| 390 |
+
subject = ticket.get("subject") or ticket.get("summary") or "Untitled"
|
| 391 |
+
|
| 392 |
+
base = {
|
| 393 |
+
"title": f"[SLA {result['sla_status'].upper()}] Ticket #{ticket_id}",
|
| 394 |
+
"subject": subject,
|
| 395 |
+
"priority": priority,
|
| 396 |
+
"status": result["sla_status"],
|
| 397 |
+
"escalation_level": result["escalation_level"],
|
| 398 |
+
"remaining_seconds": result["remaining_seconds"],
|
| 399 |
+
"ticket_url": f"https://helpdeskaiv1.vercel.app/admin/ticket/{ticket['id']}",
|
| 400 |
+
"assigned_team": ticket.get("assigned_team", "Unassigned"),
|
| 401 |
+
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
if channel_type == ChannelType.SLACK:
|
| 405 |
+
color = "#ef4444" if result["sla_status"] == "breached" else "#f59e0b"
|
| 406 |
+
return {
|
| 407 |
+
"attachments": [{
|
| 408 |
+
"color": color,
|
| 409 |
+
"title": base["title"],
|
| 410 |
+
"text": f"*Ticket:* <{base['ticket_url']}|#{ticket_id}> — {subject}\n"
|
| 411 |
+
f"*Priority:* {priority} | *Team:* {base['assigned_team']}\n"
|
| 412 |
+
f"*Remaining:* {self._fmt_remaining(result['remaining_seconds'])}",
|
| 413 |
+
"footer": "HELPDESK.AI SLA Engine",
|
| 414 |
+
"ts": int(datetime.datetime.utcnow().timestamp()),
|
| 415 |
+
}]
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
elif channel_type == ChannelType.TEAMS:
|
| 419 |
+
fact_section = {
|
| 420 |
+
"facts": [
|
| 421 |
+
{"name": "Ticket", "value": f"[#{ticket_id}]({base['ticket_url']})"},
|
| 422 |
+
{"name": "Subject", "value": subject},
|
| 423 |
+
{"name": "Priority", "value": priority},
|
| 424 |
+
{"name": "Team", "value": base["assigned_team"]},
|
| 425 |
+
{"name": "SLA Status", "value": result["sla_status"].upper()},
|
| 426 |
+
{"name": "Escalation Level", "value": str(result["escalation_level"])},
|
| 427 |
+
]
|
| 428 |
+
}
|
| 429 |
+
return {
|
| 430 |
+
"@type": "MessageCard",
|
| 431 |
+
"@context": "https://schema.org/extensions",
|
| 432 |
+
"summary": base["title"],
|
| 433 |
+
"themeColor": color if result["sla_status"] == "breached" else "f59e0b",
|
| 434 |
+
"sections": [{
|
| 435 |
+
"activityTitle": base["title"],
|
| 436 |
+
"activitySubtitle": subject,
|
| 437 |
+
**fact_section,
|
| 438 |
+
}],
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
elif channel_type == ChannelType.EMAIL:
|
| 442 |
+
return {
|
| 443 |
+
"type": "SLA_ALERT",
|
| 444 |
+
"to": channel.get("to", "support@helpdeskai.com"),
|
| 445 |
+
"subject": base["title"],
|
| 446 |
+
"template_data": {
|
| 447 |
+
"title": f"SLA {result['sla_status'].upper()}: Ticket #{ticket_id}",
|
| 448 |
+
"badge": f"🚨 Escalation Level {result['escalation_level']}",
|
| 449 |
+
"mainText": f"Priority {priority} ticket \"{subject}\" has reached SLA status: {result['sla_status']}.",
|
| 450 |
+
"refLabel": "Time Remaining",
|
| 451 |
+
"refValue": self._fmt_remaining(result["remaining_seconds"]),
|
| 452 |
+
"ctaText": "View Ticket",
|
| 453 |
+
"ctaUrl": base["ticket_url"],
|
| 454 |
+
}
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
# Generic webhook fallback
|
| 458 |
+
return base
|
| 459 |
+
|
| 460 |
+
@staticmethod
|
| 461 |
+
def _fmt_remaining(seconds: int) -> str:
|
| 462 |
+
"""Format seconds into human-readable remaining time."""
|
| 463 |
+
if seconds <= 0:
|
| 464 |
+
return "OVERDUE"
|
| 465 |
+
hrs = seconds // 3600
|
| 466 |
+
mins = (seconds % 3600) // 60
|
| 467 |
+
if hrs > 0:
|
| 468 |
+
return f"{hrs}h {mins}m"
|
| 469 |
+
return f"{mins}m"
|
| 470 |
+
|
| 471 |
+
# ------------------------------------------------------------------
|
| 472 |
+
# SLA Dashboard stats
|
| 473 |
+
# ------------------------------------------------------------------
|
| 474 |
+
|
| 475 |
+
async def get_dashboard_stats(self) -> dict:
|
| 476 |
+
"""Aggregate SLA statistics for dashboard display."""
|
| 477 |
+
if not self.supabase:
|
| 478 |
+
return {"error": "No database connection"}
|
| 479 |
+
|
| 480 |
+
try:
|
| 481 |
+
res = self.supabase.table("tickets") \
|
| 482 |
+
.select("id, priority, sla_status, status, escalation_level") \
|
| 483 |
+
.execute()
|
| 484 |
+
except Exception as e:
|
| 485 |
+
return {"error": str(e)}
|
| 486 |
+
|
| 487 |
+
tickets = res.data or []
|
| 488 |
+
total = len(tickets)
|
| 489 |
+
active_tickets = [t for t in tickets if not any(
|
| 490 |
+
s in (t.get("status") or "").lower() for s in ["resolv", "closed"]
|
| 491 |
+
)]
|
| 492 |
+
|
| 493 |
+
counts = {
|
| 494 |
+
"total": total,
|
| 495 |
+
"active": len(active_tickets),
|
| 496 |
+
"breached": sum(1 for t in tickets if t.get("sla_status") == "breached"),
|
| 497 |
+
"warning": sum(1 for t in tickets if t.get("sla_status") == "warning"),
|
| 498 |
+
"met": sum(1 for t in tickets if t.get("sla_status") == "met"),
|
| 499 |
+
"by_priority": {},
|
| 500 |
+
"breach_rate": 0,
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
if total > 0:
|
| 504 |
+
counts["breach_rate"] = round(counts["breached"] / total * 100, 1)
|
| 505 |
+
|
| 506 |
+
for p in ["critical", "high", "medium", "low"]:
|
| 507 |
+
ptickets = [t for t in active_tickets if (t.get("priority") or "").lower() == p]
|
| 508 |
+
counts["by_priority"][p] = {
|
| 509 |
+
"total": len(ptickets),
|
| 510 |
+
"breached": sum(1 for t in ptickets if t.get("sla_status") == "breached"),
|
| 511 |
+
"warning": sum(1 for t in ptickets if t.get("sla_status") == "warning"),
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
return counts
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
# ---------------------------------------------------------------------------
|
| 518 |
+
# Standalone helper — get policy for a priority
|
| 519 |
+
# ---------------------------------------------------------------------------
|
| 520 |
+
|
| 521 |
+
def get_sla_policy(priority: str) -> SLAMetric:
|
| 522 |
+
"""Get the SLA policy for a given priority string."""
|
| 523 |
+
return SLA_POLICIES.get(priority.lower().strip(), SLA_POLICIES["medium"])
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def compute_sla_breach_at(priority: str, from_time: datetime.datetime | None = None) -> str:
|
| 527 |
+
"""
|
| 528 |
+
Compute the ISO datetime when this ticket's SLA would breach.
|
| 529 |
+
Returns ISO string.
|
| 530 |
+
"""
|
| 531 |
+
policy = get_sla_policy(priority)
|
| 532 |
+
start = from_time or datetime.datetime.now(datetime.timezone.utc)
|
| 533 |
+
breach = start + datetime.timedelta(hours=policy["max_hours"])
|
| 534 |
+
return breach.isoformat()
|
backend/sla_checker.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SLA Background Checker — Periodic worker that evaluates ticket SLAs
|
| 3 |
+
and dispatches multi-channel escalation notifications.
|
| 4 |
+
|
| 5 |
+
Run as a standalone process:
|
| 6 |
+
python backend/sla_checker.py
|
| 7 |
+
|
| 8 |
+
Or import and use the start_background_checker() function.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
import time
|
| 14 |
+
import json
|
| 15 |
+
import asyncio
|
| 16 |
+
import logging
|
| 17 |
+
import datetime
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
# ── Project Path ──────────────────────────────────────────────────────────
|
| 21 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 22 |
+
|
| 23 |
+
# ── Logging ────────────────────────────────────────────────────────────────
|
| 24 |
+
logging.basicConfig(
|
| 25 |
+
level=logging.INFO,
|
| 26 |
+
format="[SLA-Checker %(asctime)s] %(levelname)s %(message)s",
|
| 27 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 28 |
+
)
|
| 29 |
+
logger = logging.getLogger("sla-checker")
|
| 30 |
+
|
| 31 |
+
# ── Load env ───────────────────────────────────────────────────────────────
|
| 32 |
+
from dotenv import load_dotenv
|
| 33 |
+
env_path = Path(__file__).parent / '.env'
|
| 34 |
+
load_dotenv(dotenv_path=env_path)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def create_supabase_client():
|
| 38 |
+
"""Create a Supabase client using service role key."""
|
| 39 |
+
try:
|
| 40 |
+
from supabase import create_client
|
| 41 |
+
url = os.environ.get("SUPABASE_URL")
|
| 42 |
+
key = os.environ.get("SUPABASE_SERVICE_KEY")
|
| 43 |
+
if not url or not key:
|
| 44 |
+
logger.error("SUPABASE_URL or SUPABASE_SERVICE_KEY not set")
|
| 45 |
+
return None
|
| 46 |
+
return create_client(url, key)
|
| 47 |
+
except ImportError:
|
| 48 |
+
logger.warning("supabase-py not installed — running in dry-run mode")
|
| 49 |
+
return None
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Failed to create Supabase client: {e}")
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def run_sla_check(supabase=None, dry_run: bool = False) -> dict:
|
| 56 |
+
"""
|
| 57 |
+
Execute one full SLA check cycle.
|
| 58 |
+
Returns a summary dict with counts.
|
| 59 |
+
"""
|
| 60 |
+
from backend.services.sla_engine import SLAEngine
|
| 61 |
+
|
| 62 |
+
engine = SLAEngine(supabase_client=supabase)
|
| 63 |
+
logger.info("Starting SLA check cycle...")
|
| 64 |
+
|
| 65 |
+
if dry_run:
|
| 66 |
+
logger.info("DRY RUN mode — no DB updates will be made.")
|
| 67 |
+
|
| 68 |
+
if supabase and not dry_run:
|
| 69 |
+
escalated = await engine.check_all_active_tickets()
|
| 70 |
+
if escalated:
|
| 71 |
+
logger.info(f"Dispatching notifications for {len(escalated)} escalated tickets...")
|
| 72 |
+
await engine.dispatch_notifications(escalated)
|
| 73 |
+
stats = await engine.get_dashboard_stats()
|
| 74 |
+
logger.info(f"SLA stats: {json.dumps(stats)}")
|
| 75 |
+
return {"checked": len(escalated) if escalated else 0, "stats": stats}
|
| 76 |
+
else:
|
| 77 |
+
logger.warning("No Supabase client — cannot run SLA check.")
|
| 78 |
+
return {"checked": 0, "stats": {}}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def run_sla_check_sync(supabase=None, dry_run: bool = False) -> dict:
|
| 82 |
+
"""Synchronous wrapper for run_sla_check."""
|
| 83 |
+
return asyncio.run(run_sla_check(supabase, dry_run))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def background_checker_loop(interval_minutes: int = 5, dry_run: bool = False):
|
| 87 |
+
"""
|
| 88 |
+
Run the SLA check in an infinite loop.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
interval_minutes: How often to check (default 5 min).
|
| 92 |
+
dry_run: If True, only log what would be done.
|
| 93 |
+
"""
|
| 94 |
+
supabase = create_supabase_client()
|
| 95 |
+
logger.info(
|
| 96 |
+
f"SLA Background Checker started "
|
| 97 |
+
f"(interval={interval_minutes}m, dry_run={dry_run})"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
while True:
|
| 101 |
+
try:
|
| 102 |
+
result = run_sla_check_sync(supabase, dry_run)
|
| 103 |
+
logger.info(f"Cycle complete: {json.dumps(result)}")
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Cycle failed: {e}", exc_info=True)
|
| 106 |
+
|
| 107 |
+
logger.info(f"Sleeping for {interval_minutes} minutes...")
|
| 108 |
+
time.sleep(interval_minutes * 60)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# # ── FastAPI lifespan integration ──────────────────────────────────────
|
| 112 |
+
# Use this inside lifespan() to start the checker as a background task:
|
| 113 |
+
#
|
| 114 |
+
# task = asyncio.create_task(sla_checker_loop_async(app.state.supabase))
|
| 115 |
+
#
|
| 116 |
+
# (Not a standalone asyncio loop to avoid blocking the server.)
|
| 117 |
+
|
| 118 |
+
async def sla_checker_loop_async(
|
| 119 |
+
supabase,
|
| 120 |
+
interval_seconds: int = 300,
|
| 121 |
+
):
|
| 122 |
+
"""Async SLA checker loop — suitable for FastAPI lifespan background task."""
|
| 123 |
+
from backend.services.sla_engine import SLAEngine
|
| 124 |
+
engine = SLAEngine(supabase_client=supabase)
|
| 125 |
+
logger.info(f"SLA background async checker started (interval={interval_seconds}s)")
|
| 126 |
+
|
| 127 |
+
while True:
|
| 128 |
+
try:
|
| 129 |
+
escalated = await engine.check_all_active_tickets()
|
| 130 |
+
if escalated:
|
| 131 |
+
await engine.dispatch_notifications(escalated)
|
| 132 |
+
logger.info(f"Async SLA check: {len(escalated)} escalations triggered")
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Async SLA check error: {e}")
|
| 135 |
+
|
| 136 |
+
await asyncio.sleep(interval_seconds)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ── CLI entry point ──────────────────────────────────────────────────────
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
import argparse
|
| 142 |
+
parser = argparse.ArgumentParser(description="SLA Breach & Escalation Checker")
|
| 143 |
+
parser.add_argument(
|
| 144 |
+
"--interval", type=int, default=5,
|
| 145 |
+
help="Check interval in minutes (default: 5)"
|
| 146 |
+
)
|
| 147 |
+
parser.add_argument(
|
| 148 |
+
"--dry-run", action="store_true",
|
| 149 |
+
help="Log evaluations without updating the database"
|
| 150 |
+
)
|
| 151 |
+
parser.add_argument(
|
| 152 |
+
"--once", action="store_true",
|
| 153 |
+
help="Run a single check cycle and exit"
|
| 154 |
+
)
|
| 155 |
+
args = parser.parse_args()
|
| 156 |
+
|
| 157 |
+
if args.once:
|
| 158 |
+
supabase = create_supabase_client()
|
| 159 |
+
result = run_sla_check_sync(supabase, args.dry_run)
|
| 160 |
+
print(json.dumps(result, indent=2))
|
| 161 |
+
else:
|
| 162 |
+
background_checker_loop(args.interval, args.dry_run)
|
supabase/functions/sla-notifier/index.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { serve } from "https://deno.land/std@0.177.0/http/server.ts"
|
| 2 |
+
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.42.0"
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* SLA Notifier — Multi-channel notification dispatcher for SLA breaches.
|
| 6 |
+
*
|
| 7 |
+
* Triggered by Supabase Database webhook when:
|
| 8 |
+
* 1. A ticket's sla_status changes to 'breached' or 'warning'
|
| 9 |
+
* 2. An escalation_logs row is inserted
|
| 10 |
+
*
|
| 11 |
+
* Supported channels:
|
| 12 |
+
* - Email (via Resend)
|
| 13 |
+
* - Slack (incoming webhook)
|
| 14 |
+
* - Microsoft Teams (incoming webhook)
|
| 15 |
+
* - Generic webhook
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || ""
|
| 19 |
+
const SUPABASE_SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""
|
| 20 |
+
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY") || ""
|
| 21 |
+
const FROM_EMAIL = "HELPDESK.AI SLA <bonthalamadhavi1@gmail.com>"
|
| 22 |
+
|
| 23 |
+
// ── Channel configuration from environment ────────────────────────────────
|
| 24 |
+
// Format: JSON array of channel objects
|
| 25 |
+
// SLA_CHANNELS=[{"type":"slack","url":"https://hooks.slack.com/...","enabled":true,"min_level":1}]
|
| 26 |
+
const SLA_CHANNELS_RAW = Deno.env.get("SLA_CHANNELS") || "[]"
|
| 27 |
+
let SLA_CHANNELS: any[] = []
|
| 28 |
+
try {
|
| 29 |
+
SLA_CHANNELS = JSON.parse(SLA_CHANNELS_RAW)
|
| 30 |
+
} catch { /* use empty */ }
|
| 31 |
+
|
| 32 |
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
function formatDuration(seconds: number): string {
|
| 35 |
+
if (seconds <= 0) return "OVERDUE"
|
| 36 |
+
const hrs = Math.floor(seconds / 3600)
|
| 37 |
+
const mins = Math.floor((seconds % 3600) / 60)
|
| 38 |
+
if (hrs > 0) return `${hrs}h ${mins}m`
|
| 39 |
+
return `${mins}m`
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function getColor(status: string): string {
|
| 43 |
+
if (status === "breached") return "#ef4444" // red
|
| 44 |
+
if (status === "warning") return "#f59e0b" // amber
|
| 45 |
+
return "#10b981" // green
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// ── Channel dispatchers ────────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
async function sendEmail(payload: any): Promise<boolean> {
|
| 51 |
+
if (!RESEND_API_KEY) {
|
| 52 |
+
console.warn("[SLA-Notifier] No RESEND_API_KEY configured — skipping email.")
|
| 53 |
+
return false
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const templateData = payload.template_data || {}
|
| 57 |
+
const recipient = payload.to || "support@helpdeskai.com"
|
| 58 |
+
|
| 59 |
+
const themeColor = "#ef4444"
|
| 60 |
+
const html = `<!DOCTYPE html>
|
| 61 |
+
<html>
|
| 62 |
+
<head>
|
| 63 |
+
<meta charset="UTF-8">
|
| 64 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 65 |
+
</head>
|
| 66 |
+
<body style="margin:0;padding:0;background-color:#f1f5f9;font-family:sans-serif;">
|
| 67 |
+
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="background-color:#f1f5f9;padding:40px 20px;">
|
| 68 |
+
<tr>
|
| 69 |
+
<td align="center">
|
| 70 |
+
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="max-width:600px;background-color:#ffffff;border-radius:24px;overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,0.05);">
|
| 71 |
+
<tr>
|
| 72 |
+
<td style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); padding: 40px; text-align: center;">
|
| 73 |
+
<h1 style="color:#ffffff; margin:0; font-size:28px; font-weight:900;">HELPDESK<span style="color:#fca5a5;">.AI</span></h1>
|
| 74 |
+
<p style="color:#fecaca; margin:8px 0 0; font-size:14px;">SLA Alert System</p>
|
| 75 |
+
</td>
|
| 76 |
+
</tr>
|
| 77 |
+
<tr>
|
| 78 |
+
<td style="padding: 24px 40px; border-bottom: 1px solid #f1f5f9; background-color: #fef2f2; text-align: center;">
|
| 79 |
+
<div style="display:inline-block; padding: 8px 16px; background-color: #fef2f2; border-radius: 999px; border: 1px solid #fecaca;">
|
| 80 |
+
<p style="margin:0; color:#991b1b; font-size:12px; font-weight:800; text-transform:uppercase;">${templateData.badge || "SLA NOTIFICATION"}</p>
|
| 81 |
+
</div>
|
| 82 |
+
</td>
|
| 83 |
+
</tr>
|
| 84 |
+
<tr>
|
| 85 |
+
<td style="padding: 40px;">
|
| 86 |
+
<h2 style="color:#0f172a; font-size:24px; margin: 0 0 16px;">${templateData.title || "SLA Alert"}</h2>
|
| 87 |
+
<p style="color:#64748b; font-size:16px; line-height:1.7; margin: 0 0 32px;">${templateData.mainText || ""}</p>
|
| 88 |
+
|
| 89 |
+
<div style="background-color: #0f172a; border-radius: 20px; padding: 32px; text-align:center; margin-bottom: 32px;">
|
| 90 |
+
<p style="margin:0; color:rgba(255,255,255,0.4); font-size:10px; font-weight:800; text-transform:uppercase;">${templateData.refLabel || "Status"}</p>
|
| 91 |
+
<h2 style="margin:8px 0 0; color:#ffffff; font-size:32px; font-weight:900; letter-spacing:0.1em;">${templateData.refValue || ""}</h2>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div align="center">
|
| 95 |
+
<a href="${templateData.ctaUrl || "https://helpdeskaiv1.vercel.app/admin/tickets"}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding: 18px 40px; border-radius: 16px; text-decoration:none; font-size:14px; font-weight:900;">
|
| 96 |
+
${templateData.ctaText || "View in Dashboard"} →
|
| 97 |
+
</a>
|
| 98 |
+
</div>
|
| 99 |
+
</td>
|
| 100 |
+
</tr>
|
| 101 |
+
<tr>
|
| 102 |
+
<td style="background-color:#f8fafc; padding:32px; text-align:center; border-top: 1px solid #f1f5f9;">
|
| 103 |
+
<p style="margin:0; color:#94a3b8; font-size:12px;">© 2026 HELPDESK.AI SLA Engine</p>
|
| 104 |
+
</td>
|
| 105 |
+
</tr>
|
| 106 |
+
</table>
|
| 107 |
+
</td>
|
| 108 |
+
</tr>
|
| 109 |
+
</table>
|
| 110 |
+
</body>
|
| 111 |
+
</html>`
|
| 112 |
+
|
| 113 |
+
try {
|
| 114 |
+
const res = await fetch("https://api.resend.com/emails", {
|
| 115 |
+
method: "POST",
|
| 116 |
+
headers: {
|
| 117 |
+
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
| 118 |
+
"Content-Type": "application/json",
|
| 119 |
+
},
|
| 120 |
+
body: JSON.stringify({
|
| 121 |
+
from: FROM_EMAIL,
|
| 122 |
+
to: [recipient],
|
| 123 |
+
subject: payload.subject || "[SLA Alert] HELPDESK.AI",
|
| 124 |
+
html: html,
|
| 125 |
+
}),
|
| 126 |
+
})
|
| 127 |
+
const data = await res.json()
|
| 128 |
+
console.log(`[SLA-Notifier] Email sent to ${recipient}: ${res.status}`, data)
|
| 129 |
+
return res.status < 500
|
| 130 |
+
} catch (err) {
|
| 131 |
+
console.error("[SLA-Notifier] Email send error:", err)
|
| 132 |
+
return false
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
async function sendSlack(payload: any): Promise<boolean> {
|
| 137 |
+
try {
|
| 138 |
+
const res = await fetch(payload.url, {
|
| 139 |
+
method: "POST",
|
| 140 |
+
headers: { "Content-Type": "application/json" },
|
| 141 |
+
body: JSON.stringify(payload.body || {}),
|
| 142 |
+
})
|
| 143 |
+
console.log(`[SLA-Notifier] Slack sent: ${res.status}`)
|
| 144 |
+
return res.ok
|
| 145 |
+
} catch (err) {
|
| 146 |
+
console.error("[SLA-Notifier] Slack error:", err)
|
| 147 |
+
return false
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
async function sendTeams(payload: any): Promise<boolean> {
|
| 152 |
+
try {
|
| 153 |
+
const res = await fetch(payload.url, {
|
| 154 |
+
method: "POST",
|
| 155 |
+
headers: { "Content-Type": "application/json" },
|
| 156 |
+
body: JSON.stringify(payload.body || {}),
|
| 157 |
+
})
|
| 158 |
+
console.log(`[SLA-Notifier] Teams sent: ${res.status}`)
|
| 159 |
+
return res.ok
|
| 160 |
+
} catch (err) {
|
| 161 |
+
console.error("[SLA-Notifier] Teams error:", err)
|
| 162 |
+
return false
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
async function sendWebhook(payload: any): Promise<boolean> {
|
| 167 |
+
try {
|
| 168 |
+
const res = await fetch(payload.url, {
|
| 169 |
+
method: "POST",
|
| 170 |
+
headers: { "Content-Type": "application/json" },
|
| 171 |
+
body: JSON.stringify(payload.body || {}),
|
| 172 |
+
})
|
| 173 |
+
console.log(`[SLA-Notifier] Webhook sent: ${res.status}`)
|
| 174 |
+
return res.ok
|
| 175 |
+
} catch (err) {
|
| 176 |
+
console.error("[SLA-Notifier] Webhook error:", err)
|
| 177 |
+
return false
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// ── Build a Slack-compatible attachment ────────────────────────────────────
|
| 182 |
+
|
| 183 |
+
function buildSlackMessage(ticket: any, slaResult: any): any {
|
| 184 |
+
const status = slaResult?.sla_status || "breached"
|
| 185 |
+
const level = slaResult?.escalation_level || 1
|
| 186 |
+
const color = getColor(status)
|
| 187 |
+
const ticketId = (ticket?.id || "???").toString().slice(0, 8).toUpperCase()
|
| 188 |
+
const subject = ticket?.subject || ticket?.summary || "Untitled"
|
| 189 |
+
const priority = (ticket?.priority || "medium").toUpperCase()
|
| 190 |
+
const team = ticket?.assigned_team || "Unassigned"
|
| 191 |
+
|
| 192 |
+
return {
|
| 193 |
+
attachments: [{
|
| 194 |
+
color,
|
| 195 |
+
title: `[SLA ${status.toUpperCase()}] Ticket #${ticketId}`,
|
| 196 |
+
text: [
|
| 197 |
+
`*Ticket:* #${ticketId} — ${subject}`,
|
| 198 |
+
`*Priority:* ${priority} | *Team:* ${team}`,
|
| 199 |
+
`*Escalation Level:* ${level}`,
|
| 200 |
+
`*Time Remaining:* ${formatDuration(slaResult?.remaining_seconds || 0)}`,
|
| 201 |
+
].join("\n"),
|
| 202 |
+
footer: "HELPDESK.AI SLA Engine",
|
| 203 |
+
ts: Math.floor(Date.now() / 1000),
|
| 204 |
+
}],
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function buildTeamsMessage(ticket: any, slaResult: any): any {
|
| 209 |
+
const ticketId = (ticket?.id || "???").toString().slice(0, 8).toUpperCase()
|
| 210 |
+
const color = getColor(slaResult?.sla_status || "breached")
|
| 211 |
+
return {
|
| 212 |
+
"@type": "MessageCard",
|
| 213 |
+
"@context": "https://schema.org/extensions",
|
| 214 |
+
summary: `[SLA ${(slaResult?.sla_status || "").toUpperCase()}] Ticket #${ticketId}`,
|
| 215 |
+
themeColor: color,
|
| 216 |
+
sections: [{
|
| 217 |
+
activityTitle: `SLA Alert: Ticket #${ticketId}`,
|
| 218 |
+
activitySubtitle: ticket?.subject || ticket?.summary || "",
|
| 219 |
+
facts: [
|
| 220 |
+
{ name: "Status", value: (slaResult?.sla_status || "").toUpperCase() },
|
| 221 |
+
{ name: "Priority", value: (ticket?.priority || "medium").toUpperCase() },
|
| 222 |
+
{ name: "Level", value: String(slaResult?.escalation_level || 0) },
|
| 223 |
+
{ name: "Team", value: ticket?.assigned_team || "Unassigned" },
|
| 224 |
+
{ name: "Remaining", value: formatDuration(slaResult?.remaining_seconds || 0) },
|
| 225 |
+
],
|
| 226 |
+
}],
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// ── Main handler ───────────────────────────────────────────────────────────
|
| 231 |
+
|
| 232 |
+
serve(async (req: Request) => {
|
| 233 |
+
try {
|
| 234 |
+
const payload = await req.json()
|
| 235 |
+
|
| 236 |
+
// Support both webhook trigger and direct API call
|
| 237 |
+
const { type, record, ticket, sla_result, channel_urls } = payload
|
| 238 |
+
const isWebhook = type === "INSERT" && record?.table === "escalation_logs"
|
| 239 |
+
|
| 240 |
+
if (isWebhook || type === "SLA_ALERT") {
|
| 241 |
+
const escalationRecord = isWebhook ? record : payload
|
| 242 |
+
const targetTicket = ticket || escalationRecord
|
| 243 |
+
|
| 244 |
+
// Determine which channels to use
|
| 245 |
+
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)
|
| 246 |
+
|
| 247 |
+
// Build common notification payload
|
| 248 |
+
const ticketId = (targetTicket.id || targetTicket.ticket_id || "???").toString().slice(0, 8)
|
| 249 |
+
const status = sla_result?.sla_status || escalationRecord.sla_status || "breached"
|
| 250 |
+
const level = sla_result?.escalation_level || escalationRecord.escalation_level || 1
|
| 251 |
+
const subject = targetTicket.subject || targetTicket.ticket_subject || "Untitled"
|
| 252 |
+
|
| 253 |
+
// Dispatch to configured channels
|
| 254 |
+
const results: string[] = []
|
| 255 |
+
|
| 256 |
+
for (const channel of SLA_CHANNELS) {
|
| 257 |
+
if (!channel.enabled) continue
|
| 258 |
+
if ((channel.min_level || 0) > level) continue
|
| 259 |
+
|
| 260 |
+
const channelType = channel.type || "webhook"
|
| 261 |
+
let success = false
|
| 262 |
+
|
| 263 |
+
switch (channelType) {
|
| 264 |
+
case "email": {
|
| 265 |
+
const emailPayload = {
|
| 266 |
+
to: channel.to || "support@helpdeskai.com",
|
| 267 |
+
subject: `[SLA ${status.toUpperCase()}] Ticket #${ticketId}`,
|
| 268 |
+
template_data: {
|
| 269 |
+
title: `${status.toUpperCase()}: Ticket #${ticketId}`,
|
| 270 |
+
badge: `🚨 Escalation Level ${level}`,
|
| 271 |
+
mainText: `Priority ${(targetTicket.priority || "medium").toUpperCase()} ticket "${subject}" has reached SLA status: ${status}.`,
|
| 272 |
+
refLabel: "Time Remaining",
|
| 273 |
+
refValue: formatDuration(sla_result?.remaining_seconds || 0),
|
| 274 |
+
ctaText: "View Ticket",
|
| 275 |
+
ctaUrl: `https://helpdeskaiv1.vercel.app/admin/ticket/${targetTicket.id || ""}`,
|
| 276 |
+
},
|
| 277 |
+
}
|
| 278 |
+
success = await sendEmail(emailPayload)
|
| 279 |
+
break
|
| 280 |
+
}
|
| 281 |
+
case "slack":
|
| 282 |
+
success = await sendSlack({
|
| 283 |
+
url: channel.url,
|
| 284 |
+
body: buildSlackMessage(targetTicket, sla_result),
|
| 285 |
+
})
|
| 286 |
+
break
|
| 287 |
+
case "teams":
|
| 288 |
+
success = await sendTeams({
|
| 289 |
+
url: channel.url,
|
| 290 |
+
body: buildTeamsMessage(targetTicket, sla_result),
|
| 291 |
+
})
|
| 292 |
+
break
|
| 293 |
+
case "webhook":
|
| 294 |
+
default:
|
| 295 |
+
success = await sendWebhook({
|
| 296 |
+
url: channel.url,
|
| 297 |
+
body: {
|
| 298 |
+
type: "sla_alert",
|
| 299 |
+
ticket_id: targetTicket.id || targetTicket.ticket_id,
|
| 300 |
+
sla_status: status,
|
| 301 |
+
escalation_level: level,
|
| 302 |
+
subject,
|
| 303 |
+
remaining_seconds: sla_result?.remaining_seconds || 0,
|
| 304 |
+
timestamp: new Date().toISOString(),
|
| 305 |
+
},
|
| 306 |
+
})
|
| 307 |
+
break
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
results.push(`${channelType}:${success ? "OK" : "FAIL"}`)
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// Log dispatched channels back to the escalation_logs record
|
| 314 |
+
if (isWebhook && escalationRecord.id) {
|
| 315 |
+
try {
|
| 316 |
+
await supabase
|
| 317 |
+
.from("escalation_logs")
|
| 318 |
+
.update({ notification_channels: results })
|
| 319 |
+
.eq("id", escalationRecord.id)
|
| 320 |
+
} catch { /* non-fatal */ }
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
return new Response(JSON.stringify({
|
| 324 |
+
status: "dispatched",
|
| 325 |
+
channels: results,
|
| 326 |
+
ticket_id: targetTicket.id || targetTicket.ticket_id,
|
| 327 |
+
}), { status: 200 })
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
// Health check
|
| 331 |
+
if (type === "HEALTH") {
|
| 332 |
+
return new Response(JSON.stringify({
|
| 333 |
+
status: "ok",
|
| 334 |
+
channels_configured: SLA_CHANNELS.length,
|
| 335 |
+
}), { status: 200 })
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
return new Response(JSON.stringify({ status: "ignored", reason: "unknown_type" }), { status: 200 })
|
| 339 |
+
} catch (err: any) {
|
| 340 |
+
console.error("[SLA-Notifier] Error:", err.message)
|
| 341 |
+
return new Response(JSON.stringify({ error: err.message }), { status: 500 })
|
| 342 |
+
}
|
| 343 |
+
})
|
supabase/migrations/20260401000000_sla_engine.sql
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- SLA Engine — Database schema for SLA breach detection and escalation
|
| 2 |
+
-- Part of Issue #75: SLA Breach & Automated Multi-Channel Escalation Engine
|
| 3 |
+
|
| 4 |
+
-- =========================================================================
|
| 5 |
+
-- 1. Extend the tickets table with SLA columns
|
| 6 |
+
-- =========================================================================
|
| 7 |
+
|
| 8 |
+
ALTER TABLE public.tickets
|
| 9 |
+
ADD COLUMN IF NOT EXISTS sla_status text
|
| 10 |
+
DEFAULT 'active'
|
| 11 |
+
CHECK (sla_status IN ('active', 'warning', 'breached', 'met', 'paused'));
|
| 12 |
+
|
| 13 |
+
ALTER TABLE public.tickets
|
| 14 |
+
ADD COLUMN IF NOT EXISTS sla_policy text
|
| 15 |
+
DEFAULT 'medium'
|
| 16 |
+
CHECK (sla_policy IN ('critical', 'high', 'medium', 'low'));
|
| 17 |
+
|
| 18 |
+
ALTER TABLE public.tickets
|
| 19 |
+
ADD COLUMN IF NOT EXISTS sla_breach_at timestamptz;
|
| 20 |
+
|
| 21 |
+
ALTER TABLE public.tickets
|
| 22 |
+
ADD COLUMN IF NOT EXISTS sla_warning_at timestamptz;
|
| 23 |
+
|
| 24 |
+
ALTER TABLE public.tickets
|
| 25 |
+
ADD COLUMN IF NOT EXISTS sla_started_at timestamptz;
|
| 26 |
+
|
| 27 |
+
ALTER TABLE public.tickets
|
| 28 |
+
ADD COLUMN IF NOT EXISTS sla_updated_at timestamptz;
|
| 29 |
+
|
| 30 |
+
ALTER TABLE public.tickets
|
| 31 |
+
ADD COLUMN IF NOT EXISTS escalation_level integer
|
| 32 |
+
DEFAULT 0
|
| 33 |
+
CHECK (escalation_level >= 0 AND escalation_level <= 3);
|
| 34 |
+
|
| 35 |
+
ALTER TABLE public.tickets
|
| 36 |
+
ADD COLUMN IF NOT EXISTS last_escalated_at timestamptz;
|
| 37 |
+
|
| 38 |
+
ALTER TABLE public.tickets
|
| 39 |
+
ADD COLUMN IF NOT EXISTS remaining_seconds integer
|
| 40 |
+
DEFAULT 0;
|
| 41 |
+
|
| 42 |
+
ALTER TABLE public.tickets
|
| 43 |
+
ADD COLUMN IF NOT EXISTS channel_notified text[]
|
| 44 |
+
DEFAULT '{}';
|
| 45 |
+
|
| 46 |
+
-- =========================================================================
|
| 47 |
+
-- 2. Escalation Logs — audit trail for every SLA escalation event
|
| 48 |
+
-- =========================================================================
|
| 49 |
+
|
| 50 |
+
CREATE TABLE IF NOT EXISTS public.escalation_logs (
|
| 51 |
+
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 52 |
+
ticket_id uuid REFERENCES public.tickets(id) ON DELETE CASCADE,
|
| 53 |
+
ticket_subject text NOT NULL DEFAULT '',
|
| 54 |
+
priority text NOT NULL DEFAULT 'medium',
|
| 55 |
+
sla_status text NOT NULL CHECK (sla_status IN ('active', 'warning', 'breached', 'met', 'paused')),
|
| 56 |
+
escalation_level integer NOT NULL DEFAULT 0,
|
| 57 |
+
remaining_seconds integer NOT NULL DEFAULT 0,
|
| 58 |
+
assigned_team text NOT NULL DEFAULT '',
|
| 59 |
+
notification_channels text[] DEFAULT '{}',
|
| 60 |
+
triggered_at timestamptz NOT NULL DEFAULT now(),
|
| 61 |
+
resolved_at timestamptz,
|
| 62 |
+
notes text DEFAULT ''
|
| 63 |
+
);
|
| 64 |
+
|
| 65 |
+
-- Index for fast dashboard queries
|
| 66 |
+
CREATE INDEX IF NOT EXISTS idx_escalation_logs_ticket_id ON public.escalation_logs(ticket_id);
|
| 67 |
+
CREATE INDEX IF NOT EXISTS idx_escalation_logs_triggered_at ON public.escalation_logs(triggered_at DESC);
|
| 68 |
+
CREATE INDEX IF NOT EXISTS idx_escalation_logs_sla_status ON public.escalation_logs(sla_status);
|
| 69 |
+
|
| 70 |
+
-- Enable RLS
|
| 71 |
+
ALTER TABLE public.escalation_logs ENABLE ROW LEVEL SECURITY;
|
| 72 |
+
|
| 73 |
+
-- Admins can read all escalation logs
|
| 74 |
+
CREATE POLICY "Admins can read escalation logs" ON public.escalation_logs
|
| 75 |
+
FOR SELECT
|
| 76 |
+
USING (
|
| 77 |
+
EXISTS (
|
| 78 |
+
SELECT 1 FROM public.profiles
|
| 79 |
+
WHERE profiles.id = auth.uid()
|
| 80 |
+
AND (profiles.role = 'admin' OR profiles.role = 'master_admin')
|
| 81 |
+
)
|
| 82 |
+
);
|
| 83 |
+
|
| 84 |
+
-- Service role and triggers can insert
|
| 85 |
+
CREATE POLICY "Service role can insert escalation logs" ON public.escalation_logs
|
| 86 |
+
FOR INSERT
|
| 87 |
+
WITH CHECK (true);
|
| 88 |
+
|
| 89 |
+
-- =========================================================================
|
| 90 |
+
-- 3. SLA Policies — configurable SLA definitions (optional override table)
|
| 91 |
+
-- =========================================================================
|
| 92 |
+
|
| 93 |
+
CREATE TABLE IF NOT EXISTS public.sla_policies (
|
| 94 |
+
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 95 |
+
priority text NOT NULL UNIQUE CHECK (priority IN ('critical', 'high', 'medium', 'low')),
|
| 96 |
+
max_hours integer NOT NULL DEFAULT 8,
|
| 97 |
+
warning_pct numeric(4,3) NOT NULL DEFAULT 0.750,
|
| 98 |
+
auto_escalate boolean NOT NULL DEFAULT true,
|
| 99 |
+
l2_after_minutes integer NOT NULL DEFAULT 0,
|
| 100 |
+
l3_after_minutes integer NOT NULL DEFAULT 120,
|
| 101 |
+
company_id uuid REFERENCES public.companies(id) ON DELETE CASCADE,
|
| 102 |
+
is_custom boolean DEFAULT false,
|
| 103 |
+
created_at timestamptz NOT NULL DEFAULT now(),
|
| 104 |
+
updated_at timestamptz
|
| 105 |
+
);
|
| 106 |
+
|
| 107 |
+
-- Insert default SLA policies
|
| 108 |
+
INSERT INTO public.sla_policies (priority, max_hours, warning_pct, auto_escalate, l2_after_minutes, l3_after_minutes)
|
| 109 |
+
VALUES
|
| 110 |
+
('critical', 2, 0.750, true, 0, 120),
|
| 111 |
+
('high', 4, 0.750, true, 30, 240),
|
| 112 |
+
('medium', 8, 0.750, true, 60, 480),
|
| 113 |
+
('low', 24, 0.750, false, 120, 1440)
|
| 114 |
+
ON CONFLICT (priority) DO NOTHING;
|
| 115 |
+
|
| 116 |
+
ALTER TABLE public.sla_policies ENABLE ROW LEVEL SECURITY;
|
| 117 |
+
|
| 118 |
+
CREATE POLICY "Anyone can read sla_policies" ON public.sla_policies
|
| 119 |
+
FOR SELECT USING (true);
|
| 120 |
+
|
| 121 |
+
CREATE POLICY "Master admins can manage sla_policies" ON public.sla_policies
|
| 122 |
+
FOR ALL
|
| 123 |
+
USING (
|
| 124 |
+
EXISTS (
|
| 125 |
+
SELECT 1 FROM public.profiles
|
| 126 |
+
WHERE profiles.id = auth.uid()
|
| 127 |
+
AND profiles.role = 'master_admin'
|
| 128 |
+
)
|
| 129 |
+
);
|
| 130 |
+
|
| 131 |
+
-- =========================================================================
|
| 132 |
+
-- 4. Indexes on tickets for SLA queries
|
| 133 |
+
-- =========================================================================
|
| 134 |
+
|
| 135 |
+
CREATE INDEX IF NOT EXISTS idx_tickets_sla_status ON public.tickets(sla_status);
|
| 136 |
+
CREATE INDEX IF NOT EXISTS idx_tickets_escalation_level ON public.tickets(escalation_level);
|
| 137 |
+
CREATE INDEX IF NOT EXISTS idx_tickets_sla_breach_at ON public.tickets(sla_breach_at);
|
| 138 |
+
CREATE INDEX IF NOT EXISTS idx_tickets_priority_sla ON public.tickets(priority, sla_status);
|
| 139 |
+
|
| 140 |
+
-- =========================================================================
|
| 141 |
+
-- 5. Trigger: auto-set sla_policy when priority changes
|
| 142 |
+
-- =========================================================================
|
| 143 |
+
|
| 144 |
+
CREATE OR REPLACE FUNCTION public.auto_set_sla_policy()
|
| 145 |
+
RETURNS trigger AS $$
|
| 146 |
+
BEGIN
|
| 147 |
+
NEW.sla_policy := LOWER(NEW.priority);
|
| 148 |
+
-- If SLA hasn't started yet, start it now
|
| 149 |
+
IF NEW.sla_started_at IS NULL THEN
|
| 150 |
+
NEW.sla_started_at := now();
|
| 151 |
+
END IF;
|
| 152 |
+
RETURN NEW;
|
| 153 |
+
END;
|
| 154 |
+
$$ LANGUAGE plpgsql;
|
| 155 |
+
|
| 156 |
+
DROP TRIGGER IF EXISTS trg_auto_set_sla_policy ON public.tickets;
|
| 157 |
+
CREATE TRIGGER trg_auto_set_sla_policy
|
| 158 |
+
BEFORE INSERT OR UPDATE OF priority ON public.tickets
|
| 159 |
+
FOR EACH ROW
|
| 160 |
+
EXECUTE FUNCTION public.auto_set_sla_policy();
|
| 161 |
+
|
| 162 |
+
-- =========================================================================
|
| 163 |
+
-- 6. Trigger: log when sla_status changes to breached
|
| 164 |
+
-- =========================================================================
|
| 165 |
+
|
| 166 |
+
CREATE OR REPLACE FUNCTION public.log_sla_breach()
|
| 167 |
+
RETURNS trigger AS $$
|
| 168 |
+
BEGIN
|
| 169 |
+
IF NEW.sla_status = 'breached' AND (OLD.sla_status IS DISTINCT FROM 'breached') THEN
|
| 170 |
+
INSERT INTO public.escalation_logs (
|
| 171 |
+
ticket_id, ticket_subject, priority, sla_status,
|
| 172 |
+
escalation_level, remaining_seconds, assigned_team,
|
| 173 |
+
notification_channels, triggered_at
|
| 174 |
+
) VALUES (
|
| 175 |
+
NEW.id,
|
| 176 |
+
COALESCE(NEW.subject, NEW.summary, ''),
|
| 177 |
+
NEW.priority,
|
| 178 |
+
'breached',
|
| 179 |
+
COALESCE(NEW.escalation_level, 1),
|
| 180 |
+
COALESCE(NEW.remaining_seconds, 0),
|
| 181 |
+
COALESCE(NEW.assigned_team, ''),
|
| 182 |
+
'{}',
|
| 183 |
+
now()
|
| 184 |
+
);
|
| 185 |
+
END IF;
|
| 186 |
+
RETURN NEW;
|
| 187 |
+
END;
|
| 188 |
+
$$ LANGUAGE plpgsql;
|
| 189 |
+
|
| 190 |
+
DROP TRIGGER IF EXISTS trg_log_sla_breach ON public.tickets;
|
| 191 |
+
CREATE TRIGGER trg_log_sla_breach
|
| 192 |
+
AFTER UPDATE OF sla_status ON public.tickets
|
| 193 |
+
FOR EACH ROW
|
| 194 |
+
WHEN (NEW.sla_status = 'breached')
|
| 195 |
+
EXECUTE FUNCTION public.log_sla_breach();
|
| 196 |
+
|
| 197 |
+
-- =========================================================================
|
| 198 |
+
-- 7. Grant permissions
|
| 199 |
+
-- =========================================================================
|
| 200 |
+
|
| 201 |
+
GRANT ALL ON TABLE public.escalation_logs TO authenticated;
|
| 202 |
+
GRANT ALL ON TABLE public.escalation_logs TO service_role;
|
| 203 |
+
GRANT SELECT ON TABLE public.sla_policies TO authenticated;
|
| 204 |
+
GRANT ALL ON TABLE public.sla_policies TO service_role;
|
supabase/migrations/20260402000000_semantic_duplicates.sql
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Semantic Duplicate Detection — pgvector-powered ticket similarity search
|
| 2 |
+
-- Issue #74: AI-Driven Semantic Duplicate Ticket Detection with Vector Embeddings
|
| 3 |
+
|
| 4 |
+
-- =========================================================================
|
| 5 |
+
-- 1. Enable pgvector extension
|
| 6 |
+
-- =========================================================================
|
| 7 |
+
|
| 8 |
+
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions;
|
| 9 |
+
|
| 10 |
+
-- =========================================================================
|
| 11 |
+
-- 2. Add vector column to tickets table
|
| 12 |
+
-- =========================================================================
|
| 13 |
+
|
| 14 |
+
ALTER TABLE public.tickets
|
| 15 |
+
ADD COLUMN IF NOT EXISTS description_vector vector(384);
|
| 16 |
+
|
| 17 |
+
-- Index for fast cosine similarity queries (IVFFlat is good for <100K rows)
|
| 18 |
+
CREATE INDEX IF NOT EXISTS idx_tickets_description_vector
|
| 19 |
+
ON public.tickets
|
| 20 |
+
USING ivfflat (description_vector vector_cosine_ops)
|
| 21 |
+
WITH (lists = 100);
|
| 22 |
+
|
| 23 |
+
-- =========================================================================
|
| 24 |
+
-- 3. Similarity search RPC — company-scoped cosine similarity
|
| 25 |
+
-- =========================================================================
|
| 26 |
+
|
| 27 |
+
-- Drop existing if present
|
| 28 |
+
DROP FUNCTION IF EXISTS match_tickets;
|
| 29 |
+
|
| 30 |
+
CREATE OR REPLACE FUNCTION match_tickets(
|
| 31 |
+
query_vector vector(384),
|
| 32 |
+
match_threshold float DEFAULT 0.70,
|
| 33 |
+
match_count int DEFAULT 5,
|
| 34 |
+
tenant_company_id uuid DEFAULT NULL
|
| 35 |
+
)
|
| 36 |
+
RETURNS TABLE(
|
| 37 |
+
id uuid,
|
| 38 |
+
ticket_id text,
|
| 39 |
+
subject text,
|
| 40 |
+
summary text,
|
| 41 |
+
description text,
|
| 42 |
+
priority text,
|
| 43 |
+
status text,
|
| 44 |
+
assigned_team text,
|
| 45 |
+
company_id uuid,
|
| 46 |
+
similarity float,
|
| 47 |
+
created_at timestamptz
|
| 48 |
+
)
|
| 49 |
+
LANGUAGE plpgsql
|
| 50 |
+
AS $$
|
| 51 |
+
BEGIN
|
| 52 |
+
RETURN QUERY
|
| 53 |
+
SELECT
|
| 54 |
+
t.id,
|
| 55 |
+
t.ticket_id,
|
| 56 |
+
t.subject,
|
| 57 |
+
t.summary,
|
| 58 |
+
t.description,
|
| 59 |
+
t.priority,
|
| 60 |
+
t.status,
|
| 61 |
+
t.assigned_team,
|
| 62 |
+
t.company_id,
|
| 63 |
+
1 - (t.description_vector <=> query_vector) AS similarity,
|
| 64 |
+
t.created_at
|
| 65 |
+
FROM public.tickets t
|
| 66 |
+
WHERE
|
| 67 |
+
t.description_vector IS NOT NULL
|
| 68 |
+
AND (1 - (t.description_vector <=> query_vector)) > match_threshold
|
| 69 |
+
AND (tenant_company_id IS NULL OR t.company_id = tenant_company_id)
|
| 70 |
+
-- Exclude resolved/closed tickets from duplicate matching
|
| 71 |
+
AND (t.status IS NULL OR (
|
| 72 |
+
LOWER(t.status) NOT LIKE '%resolv%'
|
| 73 |
+
AND LOWER(t.status) NOT LIKE '%closed%'
|
| 74 |
+
))
|
| 75 |
+
ORDER BY t.description_vector <=> query_vector
|
| 76 |
+
LIMIT match_count;
|
| 77 |
+
END;
|
| 78 |
+
$$;
|
| 79 |
+
|
| 80 |
+
-- =========================================================================
|
| 81 |
+
-- 4. System settings table for dynamic duplicate_sensitivity
|
| 82 |
+
-- =========================================================================
|
| 83 |
+
|
| 84 |
+
CREATE TABLE IF NOT EXISTS public.system_settings (
|
| 85 |
+
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 86 |
+
key text NOT NULL UNIQUE,
|
| 87 |
+
value jsonb NOT NULL DEFAULT '{}'::jsonb,
|
| 88 |
+
description text DEFAULT '',
|
| 89 |
+
updated_at timestamptz DEFAULT now()
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
-- Insert default duplicate sensitivity
|
| 93 |
+
INSERT INTO public.system_settings (key, value, description)
|
| 94 |
+
VALUES (
|
| 95 |
+
'duplicate_detection',
|
| 96 |
+
'{"sensitivity": 0.85, "enabled": true, "max_candidates": 5}'::jsonb,
|
| 97 |
+
'Duplicate detection configuration: sensitivity (0.0-1.0), enabled, max_candidates'
|
| 98 |
+
)
|
| 99 |
+
ON CONFLICT (key) DO NOTHING;
|
| 100 |
+
|
| 101 |
+
-- =========================================================================
|
| 102 |
+
-- 5. Add parent_ticket_id for linking duplicates
|
| 103 |
+
-- =========================================================================
|
| 104 |
+
|
| 105 |
+
ALTER TABLE public.tickets
|
| 106 |
+
ADD COLUMN IF NOT EXISTS parent_ticket_id uuid REFERENCES public.tickets(id) ON DELETE SET NULL;
|
| 107 |
+
|
| 108 |
+
ALTER TABLE public.tickets
|
| 109 |
+
ADD COLUMN IF NOT EXISTS is_potential_duplicate boolean DEFAULT false;
|
| 110 |
+
|
| 111 |
+
-- Index for finding duplicate chains
|
| 112 |
+
CREATE INDEX IF NOT EXISTS idx_tickets_parent_ticket_id ON public.tickets(parent_ticket_id);
|
| 113 |
+
|
| 114 |
+
-- =========================================================================
|
| 115 |
+
-- 6. RLS for system_settings
|
| 116 |
+
-- =========================================================================
|
| 117 |
+
|
| 118 |
+
ALTER TABLE public.system_settings ENABLE ROW LEVEL SECURITY;
|
| 119 |
+
|
| 120 |
+
CREATE POLICY "Anyone can read system settings" ON public.system_settings
|
| 121 |
+
FOR SELECT USING (true);
|
| 122 |
+
|
| 123 |
+
CREATE POLICY "Master admins can manage system settings" ON public.system_settings
|
| 124 |
+
FOR ALL
|
| 125 |
+
USING (
|
| 126 |
+
EXISTS (
|
| 127 |
+
SELECT 1 FROM public.profiles
|
| 128 |
+
WHERE profiles.id = auth.uid()
|
| 129 |
+
AND profiles.role = 'master_admin'
|
| 130 |
+
)
|
| 131 |
+
);
|
| 132 |
+
|
| 133 |
+
-- =========================================================================
|
| 134 |
+
-- 7. Grant permissions
|
| 135 |
+
-- =========================================================================
|
| 136 |
+
|
| 137 |
+
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO authenticated;
|
| 138 |
+
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO service_role;
|
| 139 |
+
GRANT ALL ON TABLE public.system_settings TO authenticated;
|
| 140 |
+
GRANT ALL ON TABLE public.system_settings TO service_role;
|