ritesh19180 commited on
Commit
ca9652c
·
verified ·
1 Parent(s): 6d09f69

Upload folder using huggingface_hub

Browse files
Frontend/src/admin/pages/AdminDashboard.jsx CHANGED
@@ -1,13 +1,13 @@
1
  import React, { useMemo } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
- import { Activity, AlertTriangle, Clock, ShieldCheck } from 'lucide-react';
4
 
5
  import useAuthStore from "../../store/authStore";
 
 
6
  import { supabase } from "../../lib/supabaseClient";
7
- import useTicketsRealtime from "../../hooks/useTicketsRealtime";
8
  import StatCard from "../components/StatCard";
9
  import TicketTable from "../components/TicketTable";
10
- import { formatTimelineDate } from "../../utils/dateUtils";
11
 
12
  // Inline SVG icon components
13
  const TicketIcon = () => (
@@ -104,17 +104,32 @@ function formatSlaCountdown(deadlineMs, nowMs) {
104
  const AdminDashboard = () => {
105
  const navigate = useNavigate();
106
  const { profile } = useAuthStore();
107
- const [tickets, setTickets] = React.useState([]);
108
  const [isLoading, setIsLoading] = React.useState(true);
109
  const [nowMs, setNowMs] = React.useState(() => Date.now());
110
 
111
- useTicketsRealtime({
112
- company: profile?.company,
113
- enabled: Boolean(profile),
114
- onTicketsChange: setTickets,
115
- channelName: 'admin_dashboard_tickets_realtime',
116
- });
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  React.useEffect(() => {
119
  if (profile) {
120
  const fetchStats = async () => {
@@ -134,9 +149,14 @@ const AdminDashboard = () => {
134
  console.warn("Retrying dashboard fetch without relation...", error);
135
  const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
136
  if (basicError) throw basicError;
137
- setTickets(basicData || []);
 
 
 
138
  } else {
139
- setTickets(data || []);
 
 
140
  }
141
  } catch (err) { console.error("Dashboard fetch error:", err); }
142
  finally { setIsLoading(false); }
@@ -144,7 +164,7 @@ const AdminDashboard = () => {
144
 
145
  fetchStats();
146
  }
147
- }, [profile]);
148
 
149
  React.useEffect(() => {
150
  const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
@@ -200,13 +220,17 @@ const AdminDashboard = () => {
200
  Dashboard
201
  </h1>
202
  <p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
203
- <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'inline-block' }}></span>
204
- Real-time updates active
 
 
 
 
205
  </p>
206
  </div>
207
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: '#F0FDF4', border: '1.5px solid #BBF7D0', borderRadius: '100px' }}>
208
- <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
209
- <span style={{ fontSize: '11px', fontWeight: 700, color: '#15803d', letterSpacing: '0.08em', textTransform: 'uppercase' }}>System Active</span>
210
  </div>
211
  </div>
212
 
@@ -283,9 +307,9 @@ const AdminDashboard = () => {
283
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
284
  AI Status
285
  </h2>
286
- <div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: '100px', padding: '3px 10px' }}>
287
- <span style={{ width: 5, height: 5, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
288
- <span style={{ fontSize: '10px', fontWeight: 700, color: '#15803d' }}>LIVE SYNC</span>
289
  </div>
290
  </div>
291
  <div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
@@ -310,9 +334,9 @@ const AdminDashboard = () => {
310
  <div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
311
  <p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
312
  <div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
313
- <Activity size={10} color="#9ca3af" />
314
- <span style={{ fontSize: '9px', fontWeight: 600, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
315
- Last Synced: {formatTimelineDate(new Date())}
316
  </span>
317
  </div>
318
  </div>
 
1
  import React, { useMemo } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
+ import { Activity, AlertTriangle, Clock, ShieldCheck, Wifi, WifiOff } from 'lucide-react';
4
 
5
  import useAuthStore from "../../store/authStore";
6
+ import useTicketStore from "../../store/ticketStore";
7
+ import useWebSocket from "../../hooks/useWebSocket";
8
  import { supabase } from "../../lib/supabaseClient";
 
9
  import StatCard from "../components/StatCard";
10
  import TicketTable from "../components/TicketTable";
 
11
 
12
  // Inline SVG icon components
13
  const TicketIcon = () => (
 
104
  const AdminDashboard = () => {
105
  const navigate = useNavigate();
106
  const { profile } = useAuthStore();
 
107
  const [isLoading, setIsLoading] = React.useState(true);
108
  const [nowMs, setNowMs] = React.useState(() => Date.now());
109
 
110
+ // WebSocket connection for real-time ticket updates
111
+ const { isConnected: wsConnected, lastMessage } = useWebSocket(profile?.company);
 
 
 
 
112
 
113
+ // Read tickets from the Zustand store (populated below and updated by WS)
114
+ const tickets = useTicketStore((s) => s.tickets);
115
+ const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
116
+ const setWsConnected = useTicketStore((s) => s.setWsConnected);
117
+ const upsertTicket = useTicketStore((s) => s.upsertTicket);
118
+ const removeTicket = useTicketStore((s) => s.removeTicket);
119
+
120
+ // Sync WebSocket connection status to store
121
+ React.useEffect(() => {
122
+ setWsConnected(wsConnected);
123
+ }, [wsConnected, setWsConnected]);
124
+
125
+ // Route incoming WebSocket messages into the ticket store
126
+ React.useEffect(() => {
127
+ if (lastMessage) {
128
+ handleWsMessage(lastMessage);
129
+ }
130
+ }, [lastMessage, handleWsMessage]);
131
+
132
+ // Initial fetch — populate store from Supabase on mount
133
  React.useEffect(() => {
134
  if (profile) {
135
  const fetchStats = async () => {
 
149
  console.warn("Retrying dashboard fetch without relation...", error);
150
  const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
151
  if (basicError) throw basicError;
152
+ // Bulk-load into store (avoid duplicates)
153
+ for (const t of basicData || []) {
154
+ upsertTicket(t);
155
+ }
156
  } else {
157
+ for (const t of data || []) {
158
+ upsertTicket(t);
159
+ }
160
  }
161
  } catch (err) { console.error("Dashboard fetch error:", err); }
162
  finally { setIsLoading(false); }
 
164
 
165
  fetchStats();
166
  }
167
+ }, [profile, upsertTicket]);
168
 
169
  React.useEffect(() => {
170
  const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
 
220
  Dashboard
221
  </h1>
222
  <p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
223
+ {wsConnected ? (
224
+ <Wifi size={14} color="#22c55e" />
225
+ ) : (
226
+ <WifiOff size={14} color="#f97316" />
227
+ )}
228
+ {wsConnected ? 'WebSocket connected' : 'Reconnecting...'}
229
  </p>
230
  </div>
231
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: wsConnected ? '#F0FDF4' : '#FFF7ED', border: wsConnected ? '1.5px solid #BBF7D0' : '1.5px solid #FED7AA', borderRadius: '100px' }}>
232
+ <span style={{ width: 6, height: 6, borderRadius: '50%', background: wsConnected ? '#22c55e' : '#f97316', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
233
+ <span style={{ fontSize: '11px', fontWeight: 700, color: wsConnected ? '#15803d' : '#c2410c', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{wsConnected ? 'Live' : 'Reconnecting'}</span>
234
  </div>
235
  </div>
236
 
 
307
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
308
  AI Status
309
  </h2>
310
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: wsConnected ? '#F0FDF4' : '#FFF7ED', border: wsConnected ? '1px solid #BBF7D0' : '1px solid #FED7AA', borderRadius: '100px', padding: '3px 10px' }}>
311
+ <span style={{ width: 5, height: 5, borderRadius: '50%', background: wsConnected ? '#22c55e' : '#f97316', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
312
+ <span style={{ fontSize: '10px', fontWeight: 700, color: wsConnected ? '#15803d' : '#c2410c' }}>{wsConnected ? 'WS CONNECTED' : 'RECONNECTING'}</span>
313
  </div>
314
  </div>
315
  <div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
 
334
  <div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
335
  <p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
336
  <div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
337
+ {wsConnected ? <Activity size={10} color="#22c55e" /> : <Activity size={10} color="#f97316" />}
338
+ <span style={{ fontSize: '9px', fontWeight: 600, color: wsConnected ? '#16a34a' : '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
339
+ {wsConnected ? 'Live via WebSocket' : 'Reconnecting via WebSocket...'}
340
  </span>
341
  </div>
342
  </div>
Frontend/src/hooks/useWebSocket.js ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * useWebSocket — auto-reconnecting WebSocket hook with heartbeat support.
3
+ *
4
+ * Connects to the backend WebSocket endpoint for real-time ticket updates.
5
+ * Automatically re-establishes the connection on drop with exponential backoff.
6
+ *
7
+ * Usage:
8
+ * import useWebSocket from "../../hooks/useWebSocket";
9
+ *
10
+ * const { isConnected, sendMessage, lastMessage } = useWebSocket(companyId);
11
+ *
12
+ * // lastMessage updates on every incoming message → use in a useEffect
13
+ * useEffect(() => {
14
+ * if (lastMessage?.type === "ticket_update") {
15
+ * store.addTicket(lastMessage.ticket);
16
+ * }
17
+ * }, [lastMessage]);
18
+ */
19
+
20
+ import { useEffect, useRef, useState, useCallback } from "react";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const WS_BASE_URL = import.meta.env.VITE_WS_URL || "ws://localhost:7860";
27
+ const PING_INTERVAL_MS = 25_000; // slightly < server-side 30s so pong arrives first
28
+ const PONG_TIMEOUT_MS = 12_000; // slightly > server-side 10s timeout
29
+ const MAX_RECONNECT_DELAY_MS = 30_000;
30
+ const INITIAL_RECONNECT_DELAY_MS = 1_000;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Hook
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export default function useWebSocket(companyId) {
37
+ const [isConnected, setIsConnected] = useState(false);
38
+ const [lastMessage, setLastMessage] = useState(null);
39
+ const [connectionError, setConnectionError] = useState(null);
40
+
41
+ const wsRef = useRef(null);
42
+ const pingTimerRef = useRef(null);
43
+ const pongTimeoutRef = useRef(null);
44
+ const reconnectTimerRef = useRef(null);
45
+ const reconnectAttemptRef = useRef(0);
46
+ const mountedRef = useRef(true);
47
+ const companyIdRef = useRef(companyId);
48
+
49
+ // Keep a ref to latest companyId so the effect closure always has it
50
+ companyIdRef.current = companyId;
51
+
52
+ // ---- Cleanup helpers ---------------------------------------------------
53
+
54
+ const clearTimers = useCallback(() => {
55
+ if (pingTimerRef.current) {
56
+ clearInterval(pingTimerRef.current);
57
+ pingTimerRef.current = null;
58
+ }
59
+ if (pongTimeoutRef.current) {
60
+ clearTimeout(pongTimeoutRef.current);
61
+ pongTimeoutRef.current = null;
62
+ }
63
+ if (reconnectTimerRef.current) {
64
+ clearTimeout(reconnectTimerRef.current);
65
+ reconnectTimerRef.current = null;
66
+ }
67
+ }, []);
68
+
69
+ const cleanup = useCallback(() => {
70
+ clearTimers();
71
+ if (wsRef.current) {
72
+ wsRef.current.onopen = null;
73
+ wsRef.current.onclose = null;
74
+ wsRef.current.onmessage = null;
75
+ wsRef.current.onerror = null;
76
+ wsRef.current.close();
77
+ wsRef.current = null;
78
+ }
79
+ }, [clearTimers]);
80
+
81
+ // ---- Start heartbeat timers (called after connect) --------------------
82
+
83
+ const startHeartbeat = useCallback(() => {
84
+ // Periodic pings
85
+ pingTimerRef.current = setInterval(() => {
86
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
87
+ wsRef.current.send(JSON.stringify({ type: "pong" }));
88
+ }
89
+ }, PING_INTERVAL_MS);
90
+ }, []);
91
+
92
+ // ---- WebSocket lifecycle -----------------------------------------------
93
+
94
+ const connect = useCallback(() => {
95
+ cleanup();
96
+
97
+ const cid = companyIdRef.current;
98
+ if (!cid) return;
99
+
100
+ const url = `${WS_BASE_URL}/ws/${encodeURIComponent(cid)}`;
101
+ setConnectionError(null);
102
+
103
+ let socket;
104
+ try {
105
+ socket = new WebSocket(url);
106
+ } catch (err) {
107
+ setConnectionError(err.message || "Failed to create WebSocket");
108
+ scheduleReconnect();
109
+ return;
110
+ }
111
+ wsRef.current = socket;
112
+
113
+ socket.onopen = () => {
114
+ if (!mountedRef.current) return;
115
+ setIsConnected(true);
116
+ setConnectionError(null);
117
+ reconnectAttemptRef.current = 0;
118
+ startHeartbeat();
119
+ };
120
+
121
+ socket.onmessage = (event) => {
122
+ if (!mountedRef.current) return;
123
+ try {
124
+ const data = JSON.parse(event.data);
125
+
126
+ // Respond to server pings immediately
127
+ if (data.type === "ping") {
128
+ if (socket.readyState === WebSocket.OPEN) {
129
+ socket.send(JSON.stringify({ type: "pong" }));
130
+ }
131
+ return;
132
+ }
133
+
134
+ setLastMessage(data);
135
+ } catch {
136
+ // ignore malformed frames
137
+ }
138
+ };
139
+
140
+ socket.onclose = (event) => {
141
+ if (!mountedRef.current) return;
142
+ setIsConnected(false);
143
+ clearTimers();
144
+
145
+ // Don't reconnect on clean closes (1000 = normal, 400x = intentional)
146
+ if (event.code === 1000 || (event.code >= 4000 && event.code < 5000)) {
147
+ return;
148
+ }
149
+
150
+ scheduleReconnect();
151
+ };
152
+
153
+ socket.onerror = () => {
154
+ // onclose fires immediately after onerror, so reconnect is handled there
155
+ };
156
+ }, [cleanup, clearTimers, startHeartbeat]);
157
+
158
+ // ---- Reconnection with exponential backoff -----------------------------
159
+
160
+ const scheduleReconnect = useCallback(() => {
161
+ if (!mountedRef.current || !companyIdRef.current) return;
162
+
163
+ const attempt = reconnectAttemptRef.current;
164
+ const delay = Math.min(
165
+ INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
166
+ MAX_RECONNECT_DELAY_MS
167
+ );
168
+ reconnectAttemptRef.current = attempt + 1;
169
+
170
+ setConnectionError(`Reconnecting in ${Math.round(delay / 1000)}s...`);
171
+
172
+ reconnectTimerRef.current = setTimeout(() => {
173
+ if (mountedRef.current) connect();
174
+ }, delay);
175
+ }, [connect]);
176
+
177
+ // ---- Send helper -------------------------------------------------------
178
+
179
+ const sendMessage = useCallback(
180
+ (msg) => {
181
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
182
+ wsRef.current.send(
183
+ typeof msg === "string" ? msg : JSON.stringify(msg)
184
+ );
185
+ }
186
+ },
187
+ []
188
+ );
189
+
190
+ // ---- Main effect -------------------------------------------------------
191
+
192
+ useEffect(() => {
193
+ mountedRef.current = true;
194
+ companyIdRef.current = companyId;
195
+
196
+ if (companyId) {
197
+ connect();
198
+ }
199
+
200
+ return () => {
201
+ mountedRef.current = false;
202
+ cleanup();
203
+ };
204
+ }, [companyId, connect, cleanup]);
205
+
206
+ return { isConnected, lastMessage, connectionError, sendMessage };
207
+ }
Frontend/src/store/ticketStore.js CHANGED
@@ -3,14 +3,19 @@ import { persist } from 'zustand/middleware';
3
 
4
  const useTicketStore = create(
5
  persist(
6
- (set) => ({
7
  aiTicket: null,
8
  activeTicket: null,
9
  autoResolvedTickets: [], // For analytics
10
  tickets: [], // Global queue for admins
11
  notifications: [], // User notifications
 
 
12
  setAITicket: (data) => set({ aiTicket: data }),
13
  setActiveTicket: (ticket) => set({ activeTicket: ticket }),
 
 
 
14
  addAutoResolvedTicket: (record) => set((state) => ({
15
  autoResolvedTickets: [...state.autoResolvedTickets, record]
16
  })),
@@ -27,7 +32,7 @@ const useTicketStore = create(
27
  })),
28
  addTicket: (ticket) => set((state) => {
29
  return {
30
- tickets: [ticket, ...state.tickets]
31
  };
32
  }),
33
  upsertTicket: (ticket) => set((state) => {
@@ -52,16 +57,46 @@ const useTicketStore = create(
52
  : state.activeTicket
53
  })),
54
  updateTicket: (ticketId, updates) => set((state) => {
55
- // eslint-disable-next-line no-unused-vars
56
  const existingTicket = state.tickets.find(t => (t.id ?? t.ticket_id) === ticketId);
57
- const updatedTickets = state.tickets.map(t => ((t.id ?? t.ticket_id) === ticketId) ? { ...t, ...updates } : t);
58
- const shouldUpdateActive = ((state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId);
59
-
60
  return {
61
  tickets: updatedTickets,
62
  activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
63
  };
64
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  appendMessage: (ticketId, message) => set((state) => {
66
  const updatedTickets = state.tickets.map(t =>
67
  t.ticket_id === ticketId
 
3
 
4
  const useTicketStore = create(
5
  persist(
6
+ (set, get) => ({
7
  aiTicket: null,
8
  activeTicket: null,
9
  autoResolvedTickets: [], // For analytics
10
  tickets: [], // Global queue for admins
11
  notifications: [], // User notifications
12
+ wsConnected: false, // WebSocket connection status
13
+
14
  setAITicket: (data) => set({ aiTicket: data }),
15
  setActiveTicket: (ticket) => set({ activeTicket: ticket }),
16
+
17
+ setWsConnected: (connected) => set({ wsConnected: connected }),
18
+
19
  addAutoResolvedTicket: (record) => set((state) => ({
20
  autoResolvedTickets: [...state.autoResolvedTickets, record]
21
  })),
 
32
  })),
33
  addTicket: (ticket) => set((state) => {
34
  return {
35
+ tickets: [...state.tickets, ticket]
36
  };
37
  }),
38
  upsertTicket: (ticket) => set((state) => {
 
57
  : state.activeTicket
58
  })),
59
  updateTicket: (ticketId, updates) => set((state) => {
 
60
  const existingTicket = state.tickets.find(t => (t.id ?? t.ticket_id) === ticketId);
61
+ if (!existingTicket) return state;
62
+ const updatedTickets = state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...updates } : t);
63
+ const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
64
  return {
65
  tickets: updatedTickets,
66
  activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
67
  };
68
  }),
69
+
70
+ /**
71
+ * Route an incoming WebSocket message to the correct store action.
72
+ *
73
+ * Call this from the component that owns the WebSocket connection
74
+ * (e.g. AdminDashboard) whenever a message arrives.
75
+ */
76
+ handleWsMessage: (msg) => {
77
+ if (!msg || !msg.type) return;
78
+
79
+ const { type, event, ticket, ticket_id } = msg;
80
+
81
+ switch (type) {
82
+ case "ticket_update": {
83
+ if (!ticket) break;
84
+ if (event === "created") {
85
+ // Avoid duplicates — use upsert
86
+ get().upsertTicket(ticket);
87
+ } else if (event === "updated") {
88
+ get().upsertTicket(ticket);
89
+ } else if (event === "deleted") {
90
+ get().removeTicket(ticket_id);
91
+ }
92
+ break;
93
+ }
94
+ default:
95
+ // Ignore unknown message types (e.g. heartbeat)
96
+ break;
97
+ }
98
+ },
99
+
100
  appendMessage: (ticketId, message) => set((state) => {
101
  const updatedTickets = state.tickets.map(t =>
102
  t.ticket_id === ticketId
MobileApp/App.js CHANGED
@@ -127,17 +127,55 @@ const AppContent = () => {
127
  const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
128
 
129
  useEffect(() => {
 
 
130
  const initialize = async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  try {
132
- const { data: { session } } = await supabase.auth.getSession();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  setSession(session);
134
 
 
135
  if (session?.user) {
136
- const { data, error } = await supabase
137
  .from('profiles')
138
  .select('status, role')
139
  .eq('id', session.user.id)
140
  .single();
 
 
 
 
 
 
141
 
142
  if (error) {
143
  console.log('[AuthInit] Profile fetch error, validating session:', error.message);
@@ -146,20 +184,29 @@ const AppContent = () => {
146
  if (userError) {
147
  console.log('[AuthInit] Token validation failed. Clearing session.');
148
  setSession(null);
 
 
149
  } else {
150
- // Valid token but profiles table is temporarily offline; default to user
151
- setUserStatus('active');
152
- setUserRole('user');
153
  }
154
  } else {
155
- setUserStatus(data?.status || 'active');
156
- setUserRole(data?.role || 'user');
 
 
 
 
 
 
157
  }
158
  }
159
  } catch (e) {
160
- console.log('[AuthInit] Crash caught during initialization:', e);
161
  } finally {
162
- // Guarantee showOnboarding is resolved to a boolean to prevent React Navigation stack layout mismatch
 
163
  try {
164
  const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
165
  setShowOnboarding(onboardingDone === null);
@@ -184,12 +231,17 @@ const AppContent = () => {
184
 
185
  if (error) {
186
  console.log('[AuthChange] Profile query failed:', error.message);
187
- // Default to safe values to avoid blank screens
188
  setUserStatus('active');
189
  setUserRole('user');
190
  } else {
191
- setUserStatus(data?.status || 'active');
192
- setUserRole(data?.role || 'user');
 
 
 
 
 
 
193
  }
194
  } catch (err) {
195
  console.warn('[AuthChange] Uncaught exception inside handler:', err);
@@ -199,6 +251,10 @@ const AppContent = () => {
199
  } else {
200
  setUserStatus(null);
201
  setUserRole('user');
 
 
 
 
202
  }
203
  });
204
 
@@ -216,9 +272,15 @@ const AppContent = () => {
216
  schema: 'public',
217
  table: 'profiles',
218
  filter: `id=eq.${session.user.id}`,
219
- }, (payload) => {
220
- setUserStatus(payload.new.status);
221
- setUserRole(payload.new.role || 'user');
 
 
 
 
 
 
222
  })
223
  .subscribe();
224
 
@@ -229,7 +291,6 @@ const AppContent = () => {
229
  useEffect(() => {
230
  const handleUrl = async ({ url }) => {
231
  if (!url) return;
232
- // Parse hash fragment: helpdeskai://login#access_token=...&refresh_token=...
233
  const hashIndex = url.indexOf('#');
234
  if (hashIndex === -1) return;
235
  const hash = url.substring(hashIndex + 1);
@@ -255,9 +316,7 @@ const AppContent = () => {
255
  }
256
  };
257
 
258
- // Handle app already open
259
  const subscription = Linking.addEventListener('url', handleUrl);
260
- // Handle cold start — app launched from the link
261
  Linking.getInitialURL().then(url => url && handleUrl({ url }));
262
 
263
  return () => subscription.remove();
 
127
  const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
128
 
129
  useEffect(() => {
130
+ let finished = false;
131
+
132
  const initialize = async () => {
133
+ // Safety timeout wrapper: if anything hangs, force mount after 3 seconds
134
+ const timeoutId = setTimeout(async () => {
135
+ if (!finished) {
136
+ console.log('[AuthInit] Timeout reached (3s). Forcing mount fallback.');
137
+ try {
138
+ const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
139
+ setShowOnboarding(onboardingDone === null);
140
+ } catch (err) {
141
+ setShowOnboarding(false);
142
+ }
143
+ setLoading(false);
144
+ }
145
+ }, 3000);
146
+
147
  try {
148
+ // 1. Instantly load cached status and role from AsyncStorage (stale-while-revalidate pattern)
149
+ const [cachedStatus, cachedRole] = await Promise.all([
150
+ AsyncStorage.getItem('@user_status'),
151
+ AsyncStorage.getItem('@user_role'),
152
+ ]);
153
+
154
+ if (cachedStatus) setUserStatus(cachedStatus);
155
+ if (cachedRole) setUserRole(cachedRole);
156
+
157
+ // 2. Fetch session with a 2.5 second timeout wrapper to prevent slow refreshing from locking the app
158
+ const sessionPromise = supabase.auth.getSession();
159
+ const sessionTimeoutPromise = new Promise((_, reject) =>
160
+ setTimeout(() => reject(new Error('Session fetch timed out')), 2500)
161
+ );
162
+
163
+ const { data: { session } } = await Promise.race([sessionPromise, sessionTimeoutPromise]);
164
  setSession(session);
165
 
166
+ // 3. If session is valid, validate/fetch the user profile
167
  if (session?.user) {
168
+ const profilePromise = supabase
169
  .from('profiles')
170
  .select('status, role')
171
  .eq('id', session.user.id)
172
  .single();
173
+
174
+ const profileTimeoutPromise = new Promise((_, reject) =>
175
+ setTimeout(() => reject(new Error('Profile fetch timed out')), 2000)
176
+ );
177
+
178
+ const { data, error } = await Promise.race([profilePromise, profileTimeoutPromise]);
179
 
180
  if (error) {
181
  console.log('[AuthInit] Profile fetch error, validating session:', error.message);
 
184
  if (userError) {
185
  console.log('[AuthInit] Token validation failed. Clearing session.');
186
  setSession(null);
187
+ await AsyncStorage.removeItem('@user_status');
188
+ await AsyncStorage.removeItem('@user_role');
189
  } else {
190
+ // Valid token but profiles table is offline; retain cached or default to safe user status
191
+ if (!cachedStatus) setUserStatus('active');
192
+ if (!cachedRole) setUserRole('user');
193
  }
194
  } else {
195
+ const status = data?.status || 'active';
196
+ const role = data?.role || 'user';
197
+ setUserStatus(status);
198
+ setUserRole(role);
199
+ await Promise.all([
200
+ AsyncStorage.setItem('@user_status', status),
201
+ AsyncStorage.setItem('@user_role', role),
202
+ ]);
203
  }
204
  }
205
  } catch (e) {
206
+ console.log('[AuthInit] Exception caught during initialization:', e.message || e);
207
  } finally {
208
+ finished = true;
209
+ clearTimeout(timeoutId);
210
  try {
211
  const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
212
  setShowOnboarding(onboardingDone === null);
 
231
 
232
  if (error) {
233
  console.log('[AuthChange] Profile query failed:', error.message);
 
234
  setUserStatus('active');
235
  setUserRole('user');
236
  } else {
237
+ const status = data?.status || 'active';
238
+ const role = data?.role || 'user';
239
+ setUserStatus(status);
240
+ setUserRole(role);
241
+ await Promise.all([
242
+ AsyncStorage.setItem('@user_status', status),
243
+ AsyncStorage.setItem('@user_role', role),
244
+ ]);
245
  }
246
  } catch (err) {
247
  console.warn('[AuthChange] Uncaught exception inside handler:', err);
 
251
  } else {
252
  setUserStatus(null);
253
  setUserRole('user');
254
+ await Promise.all([
255
+ AsyncStorage.removeItem('@user_status'),
256
+ AsyncStorage.removeItem('@user_role'),
257
+ ]);
258
  }
259
  });
260
 
 
272
  schema: 'public',
273
  table: 'profiles',
274
  filter: `id=eq.${session.user.id}`,
275
+ }, async (payload) => {
276
+ const status = payload.new.status;
277
+ const role = payload.new.role || 'user';
278
+ setUserStatus(status);
279
+ setUserRole(role);
280
+ await Promise.all([
281
+ AsyncStorage.setItem('@user_status', status),
282
+ AsyncStorage.setItem('@user_role', role),
283
+ ]);
284
  })
285
  .subscribe();
286
 
 
291
  useEffect(() => {
292
  const handleUrl = async ({ url }) => {
293
  if (!url) return;
 
294
  const hashIndex = url.indexOf('#');
295
  if (hashIndex === -1) return;
296
  const hash = url.substring(hashIndex + 1);
 
316
  }
317
  };
318
 
 
319
  const subscription = Linking.addEventListener('url', handleUrl);
 
320
  Linking.getInitialURL().then(url => url && handleUrl({ url }));
321
 
322
  return () => subscription.remove();
MobileApp/metro.config.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ // metro.config.js – extend Expo's default Metro config
2
+ const { getDefaultConfig } = require('expo/metro-config');
3
+
4
+ module.exports = (async () => {
5
+ const defaultConfig = await getDefaultConfig(__dirname);
6
+ // Add any customizations here if needed (e.g., extra asset extensions)
7
+ return defaultConfig;
8
+ })();
MobileApp/package-lock.json CHANGED
@@ -31,7 +31,7 @@
31
  "react-native-screens": "~4.16.0",
32
  "react-native-svg": "15.12.1",
33
  "react-native-url-polyfill": "^3.0.0",
34
- "react-native-webview": "^13.16.1",
35
  "zustand": "^5.0.12"
36
  },
37
  "devDependencies": {
 
31
  "react-native-screens": "~4.16.0",
32
  "react-native-svg": "15.12.1",
33
  "react-native-url-polyfill": "^3.0.0",
34
+ "react-native-webview": "^13.15.0",
35
  "zustand": "^5.0.12"
36
  },
37
  "devDependencies": {
MobileApp/package.json CHANGED
@@ -32,7 +32,7 @@
32
  "react-native-screens": "~4.16.0",
33
  "react-native-svg": "15.12.1",
34
  "react-native-url-polyfill": "^3.0.0",
35
- "react-native-webview": "^13.16.1",
36
  "zustand": "^5.0.12"
37
  },
38
  "private": true,
 
32
  "react-native-screens": "~4.16.0",
33
  "react-native-svg": "15.12.1",
34
  "react-native-url-polyfill": "^3.0.0",
35
+ "react-native-webview": "^13.15.0",
36
  "zustand": "^5.0.12"
37
  },
38
  "private": true,
backend/main.py CHANGED
@@ -20,12 +20,13 @@ from contextlib import asynccontextmanager
20
  warnings.filterwarnings("ignore", message="'pin_memory'")
21
 
22
  # HF Rebuild Trigger: 2026-03-08-2030
23
- from fastapi import FastAPI, Depends, HTTPException, Request
24
  from slowapi import Limiter, _rate_limit_exceeded_handler
25
  from slowapi.util import get_remote_address
26
  from slowapi.errors import RateLimitExceeded
27
  from fastapi.middleware.cors import CORSMiddleware
28
- from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
 
29
  from fastapi.encoders import jsonable_encoder
30
  import asyncio
31
  from pathlib import Path
@@ -69,12 +70,148 @@ from backend.services.audit_service import AuditLogService, AuditLogAccessError
69
  from backend.services.onnx_service import onnx_classifier
70
  from backend.services.ner_service import NERService
71
  from backend.services.duplicate_service import DuplicateService
 
72
  from backend.services.rag_service import RagService
 
73
  from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
74
  from backend.services.redis_cache import redis_cache
75
  from backend.auth_cookie import router as auth_cookie_router, get_current_user # noqa: F401
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  # ---------------------------------------------------------------------------
79
  # Request / Response models
80
  # ---------------------------------------------------------------------------
@@ -220,6 +357,14 @@ class EntityInfo(BaseModel):
220
  confidence: float
221
 
222
 
 
 
 
 
 
 
 
 
223
  class TicketResponse(BaseModel):
224
  id: str | int | None = None
225
  ticket_id: str | None = None
@@ -245,6 +390,7 @@ class TicketResponse(BaseModel):
245
  source_language: str = "en"
246
  source_language_name: str = "English"
247
  was_translated: bool = False
 
248
  version: str = "2.1.0-Neural-Diagnostic"
249
 
250
 
@@ -308,12 +454,11 @@ class ReadinessResponse(BaseModel):
308
  # ---------------------------------------------------------------------------
309
  # Service singletons
310
  # ---------------------------------------------------------------------------
311
- from backend.services.semantic_duplicate_service import SemanticDuplicateService
312
-
313
  classifier_service = ClassifierService()
314
  ner_service = NERService()
315
  duplicate_service = DuplicateService()
316
  rag_service = RagService()
 
317
  sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
318
  semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
319
 
@@ -464,7 +609,19 @@ async def lifespan(app: FastAPI):
464
  print("[Startup] Classifier V2 Shadow: Ready.")
465
  print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
466
  print("[Startup] Ready.")
 
 
 
 
 
467
  yield
 
 
 
 
 
 
 
468
  print("[Shutdown] Cleaning up ...")
469
 
470
 
@@ -891,21 +1048,11 @@ async def save_ticket(request_body: TicketSaveRequest):
891
  final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
892
  final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
893
 
894
- import hashlib
895
  user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
896
  logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
897
 
898
  duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
899
- duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85)
900
- duplicate_result = {
901
- "is_duplicate": False,
902
- "duplicate_ticket_id": None,
903
- "parent_ticket_id": None,
904
- "is_potential_duplicate": False,
905
- "similarity": 0.0,
906
- }
907
- user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
908
- logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
909
 
910
 
911
  # Semantic duplicate check BEFORE inserting the ticket
@@ -1005,12 +1152,71 @@ async def save_ticket(request_body: TicketSaveRequest):
1005
  response["parent_subject"] = duplicate_check_result.get("parent_subject")
1006
  response["similarity"] = duplicate_check_result["similarity"]
1007
  response["candidates"] = duplicate_check_result.get("candidates", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1008
  return response
1009
 
1010
  except Exception as e:
1011
  traceback.print_exc()
1012
  raise HTTPException(status_code=500, detail=str(e))
1013
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1014
  @app.get("/tickets/{ticket_id}")
1015
  async def get_ticket_by_id(
1016
  request: Request,
@@ -1148,8 +1354,9 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
1148
  text = f"{text} {local_ocr_text}".strip()
1149
  print(f"[AI] OCR added {len(local_ocr_text)} chars to context.")
1150
 
1151
- # Initalize Timeline
1152
- return await analyze_only(request_body)
 
1153
 
1154
  @app.post("/ai/analyze")
1155
  async def analyze_only(request_body: TicketRequest):
@@ -1202,8 +1409,6 @@ async def analyze_only(request_body: TicketRequest):
1202
  highlights=[],
1203
  timeline={"received": _dt.datetime.utcnow().isoformat() + "Z"},
1204
  env_metadata={},
1205
- is_potential_duplicate=False,
1206
- parent_ticket_id=None,
1207
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
1208
  original_text=request_body.text,
1209
  source_language=translation_ctx["source_language"],
@@ -1240,6 +1445,16 @@ async def analyze_only(request_body: TicketRequest):
1240
 
1241
  summary = text[:100] + ("…" if len(text) > 100 else "")
1242
 
 
 
 
 
 
 
 
 
 
 
1243
  # --- Classification ---
1244
  classification = classify_ticket_text(text)
1245
 
@@ -1282,10 +1497,18 @@ async def analyze_only(request_body: TicketRequest):
1282
  decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
1283
  if rag_match:
1284
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
 
 
 
 
 
 
1285
 
1286
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1287
  if classification["auto_resolve"]:
1288
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
 
 
1289
 
1290
  timeline["routed"] = get_now_ist()
1291
 
@@ -1317,6 +1540,7 @@ async def analyze_only(request_body: TicketRequest):
1317
  highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1318
  timeline=timeline,
1319
  env_metadata=env_metadata,
 
1320
  is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1321
  parent_ticket_id=dup_result.get("parent_ticket_id"),
1322
  sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z"),
@@ -1358,6 +1582,15 @@ async def analyze_stream(request_body: TicketRequest):
1358
 
1359
  summary = text[:100] + ("…" if len(text) > 100 else "")
1360
 
 
 
 
 
 
 
 
 
 
1361
  # 2. NER
1362
  yield f"data: {json.dumps({'step': 'Extracting technical entities', 'status': 'in_progress'})}\n\n"
1363
  await asyncio.sleep(0.2)
@@ -1404,10 +1637,18 @@ async def analyze_stream(request_body: TicketRequest):
1404
  decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
1405
  if rag_match:
1406
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
 
 
 
 
 
 
1407
 
1408
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1409
  if classification["auto_resolve"]:
1410
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
 
 
1411
 
1412
  timeline["routed"] = get_now_ist()
1413
 
@@ -1437,6 +1678,7 @@ async def analyze_stream(request_body: TicketRequest):
1437
  "highlights": [e.get("text", "") for e in entities],
1438
  "timeline": timeline,
1439
  "env_metadata": env_metadata,
 
1440
  "sla_breach_at": sla_breach_dt.isoformat() + "Z"
1441
  }
1442
 
@@ -1594,14 +1836,15 @@ async def sla_policies():
1594
  if not supabase:
1595
  # Return defaults from code
1596
  policies = []
1597
- for pri, cfg in sla_engine.SLA_POLICIES.items() if hasattr(sla_engine, 'SLA_POLICIES') else SLA_POLICIES.items():
 
1598
  policies.append({
1599
  "priority": pri,
1600
  "max_hours": cfg["max_hours"],
1601
  "warning_pct": cfg["warning_pct"],
1602
- "auto_escalate": cfg["auto_escalate_on_breach"],
1603
- "l2_after_minutes": cfg["l2_escalation_mins"],
1604
- "l3_after_minutes": cfg["l3_escalation_mins"],
1605
  })
1606
  return {"policies": policies}
1607
 
@@ -1659,6 +1902,7 @@ async def reindex_embeddings():
1659
  @app.get("/system/settings")
1660
  async def get_system_settings_endpoint():
1661
  """Fetch all system settings."""
 
1662
  if not supabase:
1663
  raise HTTPException(status_code=503, detail="Database not connected")
1664
  try:
@@ -1668,7 +1912,7 @@ async def get_system_settings_endpoint():
1668
  settings[row["key"]] = row["value"]
1669
  return settings
1670
  except Exception as e:
1671
- logger.warning(f"[SETTINGS] Query failed: {e}")
1672
  return {}
1673
 
1674
 
@@ -1724,3 +1968,9 @@ async def sla_ticket_detail(ticket_id: str):
1724
  "sla_evaluation": result,
1725
  "escalations": escalations,
1726
  }
 
 
 
 
 
 
 
20
  warnings.filterwarnings("ignore", message="'pin_memory'")
21
 
22
  # HF Rebuild Trigger: 2026-03-08-2030
23
+ from fastapi import FastAPI, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
24
  from slowapi import Limiter, _rate_limit_exceeded_handler
25
  from slowapi.util import get_remote_address
26
  from slowapi.errors import RateLimitExceeded
27
  from fastapi.middleware.cors import CORSMiddleware
28
+ from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
29
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
30
  from fastapi.encoders import jsonable_encoder
31
  import asyncio
32
  from pathlib import Path
 
70
  from backend.services.onnx_service import onnx_classifier
71
  from backend.services.ner_service import NERService
72
  from backend.services.duplicate_service import DuplicateService
73
+ from backend.services.semantic_duplicate_service import SemanticDuplicateService
74
  from backend.services.rag_service import RagService
75
+ from backend.services.spam_service import SpamService
76
  from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
77
  from backend.services.redis_cache import redis_cache
78
  from backend.auth_cookie import router as auth_cookie_router, get_current_user # noqa: F401
79
 
80
 
81
+ # ---------------------------------------------------------------------------
82
+ # WebSocket Connection Manager — real-time ticket dashboards
83
+ # ---------------------------------------------------------------------------
84
+
85
+ HEARTBEAT_INTERVAL = 30 # seconds between ping broadcasts
86
+ HEARTBEAT_TIMEOUT = 10 # seconds to wait for a pong before disconnect
87
+
88
+
89
+ class ConnectionManager:
90
+ """Tracks active WebSocket connections grouped by ``company_id``.
91
+
92
+ Thread-safe for concurrent connect/disconnect calls from multiple
93
+ ASGI workers (single-process via ``asyncio.Lock``).
94
+ """
95
+
96
+ def __init__(self) -> None:
97
+ self._connections: dict[str, set[WebSocket]] = {}
98
+ self._lock = asyncio.Lock()
99
+
100
+ async def connect(self, company_id: str, ws: WebSocket) -> None:
101
+ """Accept a new WebSocket and register it under ``company_id``."""
102
+ await ws.accept()
103
+ async with self._lock:
104
+ self._connections.setdefault(company_id, set()).add(ws)
105
+
106
+ async def disconnect(self, company_id: str, ws: WebSocket) -> None:
107
+ """Remove a WebSocket from the pool."""
108
+ async with self._lock:
109
+ connections = self._connections.get(company_id)
110
+ if connections:
111
+ connections.discard(ws)
112
+ # Clean up empty company groups
113
+ if not connections:
114
+ del self._connections[company_id]
115
+
116
+ async def broadcast(self, company_id: str, message: dict) -> int:
117
+ """Send a JSON message to every client in a company group.
118
+
119
+ Returns:
120
+ Number of successfully sent messages.
121
+ """
122
+ payload = json.dumps(message, default=str)
123
+ sent = 0
124
+ async with self._lock:
125
+ connections = set(self._connections.get(company_id, []))
126
+
127
+ for ws in connections:
128
+ try:
129
+ await ws.send_text(payload)
130
+ sent += 1
131
+ except Exception:
132
+ await self.disconnect(company_id, ws)
133
+ return sent
134
+
135
+ async def broadcast_all(self, message: dict) -> int:
136
+ """Send a JSON message to **all** connected clients."""
137
+ payload = json.dumps(message, default=str)
138
+ sent = 0
139
+ async with self._lock:
140
+ all_connections = {
141
+ ws for group in self._connections.values() for ws in group
142
+ }
143
+
144
+ for ws in all_connections:
145
+ try:
146
+ await ws.send_text(payload)
147
+ sent += 1
148
+ except Exception:
149
+ pass
150
+ return sent
151
+
152
+ async def ping_all(self) -> None:
153
+ """Send a ``{"type": "ping"}`` heartbeat to every connection.
154
+
155
+ Connections that fail to receive the ping are removed.
156
+ """
157
+ async with self._lock:
158
+ # Snapshot all connections under lock so iteration is safe
159
+ snapshot = {
160
+ cid: set(ws_set) for cid, ws_set in self._connections.items()
161
+ }
162
+
163
+ for cid, ws_set in snapshot.items():
164
+ for ws in list(ws_set):
165
+ try:
166
+ await ws.send_json({"type": "ping"})
167
+ except Exception:
168
+ await self.disconnect(cid, ws)
169
+
170
+ @property
171
+ def active_count(self) -> int:
172
+ """Total number of connected clients across all companies."""
173
+ return sum(len(ws_set) for ws_set in self._connections.values())
174
+
175
+
176
+ # Singleton — reused across lifespan and WebSocket route
177
+ connection_manager = ConnectionManager()
178
+
179
+
180
+ async def _heartbeat_loop() -> None:
181
+ """Background task: broadcast ping every ``HEARTBEAT_INTERVAL`` seconds.
182
+
183
+ Clients that fail the ping are disconnected automatically by
184
+ ``ConnectionManager.ping_all()``.
185
+ """
186
+ while True:
187
+ await asyncio.sleep(HEARTBEAT_INTERVAL)
188
+ try:
189
+ await connection_manager.ping_all()
190
+ count = connection_manager.active_count
191
+ if count:
192
+ print(f"[WS] Heartbeat sent to {count} active connection(s)")
193
+ except Exception as exc:
194
+ print(f"[WS] Heartbeat error: {exc}")
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # SLA helper functions (must be defined before save_ticket uses them)
199
+ # ---------------------------------------------------------------------------
200
+
201
+ def calculate_sla_breach_at(priority: str) -> datetime.datetime:
202
+ """Return the UTC datetime by which the ticket must be resolved."""
203
+ hours_map = {"critical": 2, "high": 8, "medium": 24, "low": 72}
204
+ hours = hours_map.get(str(priority).lower().strip(), 72)
205
+ return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
206
+
207
+
208
+ def calculate_sla_response_at(priority: str) -> datetime.datetime:
209
+ """Return the UTC datetime by which the ticket must receive a first response."""
210
+ hours_map = {"critical": 0.5, "high": 2, "medium": 6, "low": 18}
211
+ hours = hours_map.get(str(priority).lower().strip(), 6)
212
+ return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
213
+
214
+
215
  # ---------------------------------------------------------------------------
216
  # Request / Response models
217
  # ---------------------------------------------------------------------------
 
357
  confidence: float
358
 
359
 
360
+ class SpamCheck(BaseModel):
361
+ is_spam: bool = False
362
+ risk_score: float = 0.0
363
+ reasons: list[str] = []
364
+ suspicious_urls: list[str] = []
365
+ matched_keywords: list[str] = []
366
+
367
+
368
  class TicketResponse(BaseModel):
369
  id: str | int | None = None
370
  ticket_id: str | None = None
 
390
  source_language: str = "en"
391
  source_language_name: str = "English"
392
  was_translated: bool = False
393
+ spam_check: SpamCheck = SpamCheck()
394
  version: str = "2.1.0-Neural-Diagnostic"
395
 
396
 
 
454
  # ---------------------------------------------------------------------------
455
  # Service singletons
456
  # ---------------------------------------------------------------------------
 
 
457
  classifier_service = ClassifierService()
458
  ner_service = NERService()
459
  duplicate_service = DuplicateService()
460
  rag_service = RagService()
461
+ spam_service = SpamService()
462
  sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
463
  semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
464
 
 
609
  print("[Startup] Classifier V2 Shadow: Ready.")
610
  print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
611
  print("[Startup] Ready.")
612
+
613
+ # Start WebSocket heartbeat background loop
614
+ heartbeat_task = asyncio.create_task(_heartbeat_loop())
615
+ print("[Startup] WebSocket heartbeat loop started (interval=30s).")
616
+
617
  yield
618
+
619
+ # Cancel background tasks on shutdown
620
+ heartbeat_task.cancel()
621
+ try:
622
+ await heartbeat_task
623
+ except asyncio.CancelledError:
624
+ pass
625
  print("[Shutdown] Cleaning up ...")
626
 
627
 
 
1048
  final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
1049
  final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
1050
 
 
1051
  user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
1052
  logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
1053
 
1054
  duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
1055
+ duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85) # noqa: F841
 
 
 
 
 
 
 
 
 
1056
 
1057
 
1058
  # Semantic duplicate check BEFORE inserting the ticket
 
1152
  response["parent_subject"] = duplicate_check_result.get("parent_subject")
1153
  response["similarity"] = duplicate_check_result["similarity"]
1154
  response["candidates"] = duplicate_check_result.get("candidates", [])
1155
+
1156
+ # Broadcast the new/updated ticket to all WebSocket clients for this company
1157
+ company_id = final_data.get("company_id")
1158
+ if company_id:
1159
+ asyncio.create_task(
1160
+ connection_manager.broadcast(
1161
+ company_id,
1162
+ {
1163
+ "type": "ticket_update",
1164
+ "event": "created",
1165
+ "ticket": insert_data,
1166
+ "ticket_id": str(ticket_id),
1167
+ },
1168
+ )
1169
+ )
1170
  return response
1171
 
1172
  except Exception as e:
1173
  traceback.print_exc()
1174
  raise HTTPException(status_code=500, detail=str(e))
1175
 
1176
+ @app.websocket("/ws/{company_id}")
1177
+ async def websocket_endpoint(ws: WebSocket, company_id: str):
1178
+ """Real-time WebSocket feed for a company's ticket dashboard.
1179
+
1180
+ Protocol:
1181
+ - Server sends ``{"type": "ping"}`` every 30s (heartbeat).
1182
+ - Client must respond with ``{"type": "pong"}`` within 10s.
1183
+ - Server pushes ``{"type": "ticket_update", ...}`` on changes.
1184
+
1185
+ Usage (frontend):
1186
+ const socket = new WebSocket("ws://host:7860/ws/{company_id}");
1187
+ socket.onmessage = (event) => { const msg = JSON.parse(event.data); };
1188
+ """
1189
+ if not company_id or not company_id.strip():
1190
+ await ws.close(code=4000, reason="Missing company_id")
1191
+ return
1192
+
1193
+ company_id = company_id.strip()
1194
+ await connection_manager.connect(company_id, ws)
1195
+ print(f"[WS] Client connected — company_id={company_id}")
1196
+
1197
+ try:
1198
+ while True:
1199
+ raw = await ws.receive_text()
1200
+ if not raw.strip():
1201
+ continue
1202
+ try:
1203
+ data = json.loads(raw)
1204
+ except json.JSONDecodeError:
1205
+ continue # ignore malformed frames
1206
+
1207
+ # Handle pong response
1208
+ if data.get("type") == "pong":
1209
+ continue
1210
+
1211
+ except WebSocketDisconnect:
1212
+ pass
1213
+ except Exception as exc:
1214
+ print(f"[WS] Connection error for company_id={company_id}: {exc}")
1215
+ finally:
1216
+ await connection_manager.disconnect(company_id, ws)
1217
+ print(f"[WS] Client disconnected — company_id={company_id}")
1218
+
1219
+
1220
  @app.get("/tickets/{ticket_id}")
1221
  async def get_ticket_by_id(
1222
  request: Request,
 
1354
  text = f"{text} {local_ocr_text}".strip()
1355
  print(f"[AI] OCR added {len(local_ocr_text)} chars to context.")
1356
 
1357
+ # Pass OCR-enriched text downstream so the analyze_only endpoint uses it.
1358
+ enriched = request_body.model_copy(update={"text": text, "image_text": local_ocr_text})
1359
+ return await analyze_only(enriched)
1360
 
1361
  @app.post("/ai/analyze")
1362
  async def analyze_only(request_body: TicketRequest):
 
1409
  highlights=[],
1410
  timeline={"received": _dt.datetime.utcnow().isoformat() + "Z"},
1411
  env_metadata={},
 
 
1412
  sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
1413
  original_text=request_body.text,
1414
  source_language=translation_ctx["source_language"],
 
1445
 
1446
  summary = text[:100] + ("…" if len(text) > 100 else "")
1447
 
1448
+ # --- Spam / Phishing Detection (runs before classification) ---
1449
+ try:
1450
+ spam_result = spam_service.check(text, gemini_analysis.get("ocr_text", ""))
1451
+ except Exception as e:
1452
+ print(f"[SPAM ERROR] {e}")
1453
+ spam_result = {
1454
+ "is_spam": False, "risk_score": 0.0, "reasons": [],
1455
+ "suspicious_urls": [], "matched_keywords": [],
1456
+ }
1457
+
1458
  # --- Classification ---
1459
  classification = classify_ticket_text(text)
1460
 
 
1497
  decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
1498
  if rag_match:
1499
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1500
+ if spam_result["is_spam"]:
1501
+ decision_factors.append(
1502
+ f"Flagged as spam/phishing (risk {spam_result['risk_score']:.2f})"
1503
+ )
1504
+ classification["assigned_team"] = "Spam / Suspicious"
1505
+ classification["auto_resolve"] = False
1506
 
1507
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1508
  if classification["auto_resolve"]:
1509
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1510
+ if spam_result["is_spam"]:
1511
+ reasoning += " Ticket flagged as spam/phishing and quarantined from agent inbox."
1512
 
1513
  timeline["routed"] = get_now_ist()
1514
 
 
1540
  highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1541
  timeline=timeline,
1542
  env_metadata=env_metadata,
1543
+ spam_check=SpamCheck(**spam_result),
1544
  is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1545
  parent_ticket_id=dup_result.get("parent_ticket_id"),
1546
  sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z"),
 
1582
 
1583
  summary = text[:100] + ("…" if len(text) > 100 else "")
1584
 
1585
+ # Spam / Phishing check (silent step — does not get its own SSE event)
1586
+ try:
1587
+ spam_result = spam_service.check(text, gemini_analysis.get("ocr_text", ""))
1588
+ except Exception:
1589
+ spam_result = {
1590
+ "is_spam": False, "risk_score": 0.0, "reasons": [],
1591
+ "suspicious_urls": [], "matched_keywords": [],
1592
+ }
1593
+
1594
  # 2. NER
1595
  yield f"data: {json.dumps({'step': 'Extracting technical entities', 'status': 'in_progress'})}\n\n"
1596
  await asyncio.sleep(0.2)
 
1637
  decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
1638
  if rag_match:
1639
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1640
+ if spam_result["is_spam"]:
1641
+ decision_factors.append(
1642
+ f"Flagged as spam/phishing (risk {spam_result['risk_score']:.2f})"
1643
+ )
1644
+ classification["assigned_team"] = "Spam / Suspicious"
1645
+ classification["auto_resolve"] = False
1646
 
1647
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1648
  if classification["auto_resolve"]:
1649
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1650
+ if spam_result["is_spam"]:
1651
+ reasoning += " Ticket flagged as spam/phishing and quarantined from agent inbox."
1652
 
1653
  timeline["routed"] = get_now_ist()
1654
 
 
1678
  "highlights": [e.get("text", "") for e in entities],
1679
  "timeline": timeline,
1680
  "env_metadata": env_metadata,
1681
+ "spam_check": spam_result,
1682
  "sla_breach_at": sla_breach_dt.isoformat() + "Z"
1683
  }
1684
 
 
1836
  if not supabase:
1837
  # Return defaults from code
1838
  policies = []
1839
+ policy_source = sla_engine.SLA_POLICIES if hasattr(sla_engine, "SLA_POLICIES") else {}
1840
+ for pri, cfg in policy_source.items():
1841
  policies.append({
1842
  "priority": pri,
1843
  "max_hours": cfg["max_hours"],
1844
  "warning_pct": cfg["warning_pct"],
1845
+ "auto_escalate": cfg.get("auto_escalate_on_breach", False),
1846
+ "l2_after_minutes": cfg.get("l2_escalation_mins", 0),
1847
+ "l3_after_minutes": cfg.get("l3_escalation_mins", 0),
1848
  })
1849
  return {"policies": policies}
1850
 
 
1902
  @app.get("/system/settings")
1903
  async def get_system_settings_endpoint():
1904
  """Fetch all system settings."""
1905
+ _logger = logging.getLogger(__name__)
1906
  if not supabase:
1907
  raise HTTPException(status_code=503, detail="Database not connected")
1908
  try:
 
1912
  settings[row["key"]] = row["value"]
1913
  return settings
1914
  except Exception as e:
1915
+ _logger.warning(f"[SETTINGS] Query failed: {e}")
1916
  return {}
1917
 
1918
 
 
1968
  "sla_evaluation": result,
1969
  "escalations": escalations,
1970
  }
1971
+
1972
+
1973
+ @app.get("/metrics")
1974
+ async def metrics():
1975
+ """Prometheus scrape endpoint — exposes AI inference latency, request counts, and tokens."""
1976
+ return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
backend/requirements.txt CHANGED
@@ -22,4 +22,6 @@ pytest
22
  pytest-asyncio
23
  httpx
24
  cryptography>=42.0.0
 
 
25
 
 
22
  pytest-asyncio
23
  httpx
24
  cryptography>=42.0.0
25
+ websockets>=12.0
26
+ prometheus-client>=0.19.0
27
 
backend/services/classifier_service.py CHANGED
@@ -22,6 +22,16 @@ SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "model
22
  DEVICE = torch.device("cuda" if torch and torch.cuda.is_available() else "cpu") if _HAS_TORCH else None
23
  MAX_LEN = 128
24
 
 
 
 
 
 
 
 
 
 
 
25
  # Priority mapping based on sub-category severity
26
  PRIORITY_MAP = {
27
  "Blue Screen": "Critical", "Overheating": "Critical", "Data Loss": "Critical",
@@ -111,11 +121,22 @@ class ClassifierService:
111
  input_ids = encoding["input_ids"].to(DEVICE)
112
  attention_mask = encoding["attention_mask"].to(DEVICE)
113
 
114
- with torch.no_grad():
115
- outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
116
- logits = outputs.logits
117
- probs = F.softmax(logits, dim=1)
118
- confidence, pred_idx = torch.max(probs, dim=1)
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  pred_idx = pred_idx.item()
121
  confidence = round(confidence.item(), 4)
 
22
  DEVICE = torch.device("cuda" if torch and torch.cuda.is_available() else "cpu") if _HAS_TORCH else None
23
  MAX_LEN = 128
24
 
25
+ try:
26
+ from backend.services.metrics_service import (
27
+ CLASSIFIER_LATENCY,
28
+ CLASSIFIER_REQUESTS,
29
+ CLASSIFIER_TOKENS,
30
+ )
31
+ _METRICS_ENABLED = True
32
+ except Exception:
33
+ _METRICS_ENABLED = False
34
+
35
  # Priority mapping based on sub-category severity
36
  PRIORITY_MAP = {
37
  "Blue Screen": "Critical", "Overheating": "Critical", "Data Loss": "Critical",
 
121
  input_ids = encoding["input_ids"].to(DEVICE)
122
  attention_mask = encoding["attention_mask"].to(DEVICE)
123
 
124
+ import time
125
+ _t0 = time.perf_counter()
126
+ try:
127
+ with torch.no_grad():
128
+ outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
129
+ logits = outputs.logits
130
+ probs = F.softmax(logits, dim=1)
131
+ confidence, pred_idx = torch.max(probs, dim=1)
132
+ except Exception:
133
+ if _METRICS_ENABLED:
134
+ CLASSIFIER_REQUESTS.labels(model="distilbert", status="error").inc()
135
+ raise
136
+ if _METRICS_ENABLED:
137
+ CLASSIFIER_LATENCY.labels(model="distilbert").observe(time.perf_counter() - _t0)
138
+ CLASSIFIER_REQUESTS.labels(model="distilbert", status="ok").inc()
139
+ CLASSIFIER_TOKENS.labels(model="distilbert").inc(int(attention_mask.sum().item()))
140
 
141
  pred_idx = pred_idx.item()
142
  confidence = round(confidence.item(), 4)
backend/services/gemini_service.py CHANGED
@@ -28,7 +28,7 @@ class GeminiService:
28
  else:
29
  print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
30
 
31
- def analyze_image(self, image_base64: str) -> dict:
32
  """
33
  Perform OCR and image analysis using Gemini logic.
34
  """
@@ -47,6 +47,10 @@ class GeminiService:
47
 
48
  prompt = (
49
  "Analyze this screenshot from a user reporting a technical issue. "
 
 
 
 
50
  "1. Provide a concise description of what is shown in the image. "
51
  "2. Perform OCR and extract any error messages or key text. "
52
  "3. Identify the main technical problem depicted. "
 
28
  else:
29
  print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
30
 
31
+ def analyze_image(self, image_base64: str, context_text: str = None) -> dict:
32
  """
33
  Perform OCR and image analysis using Gemini logic.
34
  """
 
47
 
48
  prompt = (
49
  "Analyze this screenshot from a user reporting a technical issue. "
50
+ )
51
+ if context_text:
52
+ prompt += f"Context/description provided by user: '{context_text}'\n"
53
+ prompt += (
54
  "1. Provide a concise description of what is shown in the image. "
55
  "2. Perform OCR and extract any error messages or key text. "
56
  "3. Identify the main technical problem depicted. "
backend/services/spam_service.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spam & Phishing Detection Service — lightweight, dependency-free heuristics.
3
+
4
+ Scans ticket text (and OCR-extracted text) for common phishing patterns,
5
+ suspicious URLs, and social-engineering keywords. Designed to run before the
6
+ classification cascade so high-risk tickets can be flagged in the UI and
7
+ kept away from support agents' inboxes.
8
+ """
9
+
10
+ import re
11
+ from urllib.parse import urlparse
12
+
13
+
14
+ # Common phishing / social-engineering keywords. Kept conservative to avoid
15
+ # false-positives on legitimate IT tickets ("password reset" is normal).
16
+ _PHISHING_KEYWORDS = [
17
+ "verify your account",
18
+ "verify your identity",
19
+ "confirm your password",
20
+ "update your password immediately",
21
+ "account has been suspended",
22
+ "account will be closed",
23
+ "unusual sign-in activity",
24
+ "unusual login attempt",
25
+ "click here to claim",
26
+ "you have won",
27
+ "congratulations you",
28
+ "wire transfer",
29
+ "send bitcoin",
30
+ "send btc",
31
+ "gift card",
32
+ "limited time offer",
33
+ "act now",
34
+ "urgent action required",
35
+ "final notice",
36
+ ]
37
+
38
+ # Free / disposable / known-abused TLDs. Not exhaustive — a starting list.
39
+ _SUSPICIOUS_TLDS = {
40
+ "zip", "mov", "xyz", "top", "click", "country", "stream", "gq", "tk",
41
+ "ml", "cf", "ga", "work", "loan", "kim", "men",
42
+ }
43
+
44
+ # Common URL shorteners — frequently used to hide phishing destinations.
45
+ _URL_SHORTENERS = {
46
+ "bit.ly", "tinyurl.com", "goo.gl", "t.co", "ow.ly", "is.gd", "buff.ly",
47
+ "shorte.st", "adf.ly", "cutt.ly", "rebrand.ly", "rb.gy", "s.id",
48
+ }
49
+
50
+ _URL_RE = re.compile(
51
+ r"\b((?:https?://|www\.)[^\s<>\"')]+)",
52
+ re.IGNORECASE,
53
+ )
54
+
55
+ _IP_HOST_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
56
+
57
+
58
+ def extract_urls(text: str) -> list[str]:
59
+ """Return all URLs found in the text, lightly normalized."""
60
+ if not text:
61
+ return []
62
+ urls = []
63
+ for match in _URL_RE.findall(text):
64
+ url = match.rstrip(".,);:!?")
65
+ if not url.lower().startswith(("http://", "https://")):
66
+ url = "http://" + url
67
+ urls.append(url)
68
+ return urls
69
+
70
+
71
+ def _classify_url(url: str) -> str | None:
72
+ """Return a reason string if the URL looks suspicious, else None."""
73
+ try:
74
+ parsed = urlparse(url)
75
+ except ValueError:
76
+ return "Malformed URL"
77
+
78
+ host = (parsed.hostname or "").lower()
79
+ if not host:
80
+ return "URL missing host"
81
+
82
+ if _IP_HOST_RE.match(host):
83
+ return f"URL uses raw IP address ({host})"
84
+
85
+ if host in _URL_SHORTENERS:
86
+ return f"URL shortener detected ({host})"
87
+
88
+ tld = host.rsplit(".", 1)[-1] if "." in host else ""
89
+ if tld in _SUSPICIOUS_TLDS:
90
+ return f"Suspicious TLD .{tld} ({host})"
91
+
92
+ # "@" inside the authority is a classic phishing trick to hide the real host.
93
+ if "@" in (parsed.netloc or ""):
94
+ return f"URL contains embedded credentials ({parsed.netloc})"
95
+
96
+ return None
97
+
98
+
99
+ class SpamService:
100
+ """Stateless heuristic spam / phishing detector."""
101
+
102
+ # Risk score thresholds — kept on the conservative side.
103
+ SPAM_THRESHOLD = 0.6
104
+
105
+ def check(self, text: str, ocr_text: str = "") -> dict:
106
+ """
107
+ Analyze `text` (and optional `ocr_text`) and return a structured verdict.
108
+
109
+ Returns:
110
+ {
111
+ "is_spam": bool,
112
+ "risk_score": float, # 0.0–1.0
113
+ "reasons": list[str],
114
+ "suspicious_urls": list[str],
115
+ "matched_keywords": list[str],
116
+ }
117
+ """
118
+ combined = " ".join(filter(None, [text or "", ocr_text or ""])).strip()
119
+ if not combined:
120
+ return {
121
+ "is_spam": False,
122
+ "risk_score": 0.0,
123
+ "reasons": [],
124
+ "suspicious_urls": [],
125
+ "matched_keywords": [],
126
+ }
127
+
128
+ lowered = combined.lower()
129
+ matched_keywords = [kw for kw in _PHISHING_KEYWORDS if kw in lowered]
130
+
131
+ suspicious_urls: list[str] = []
132
+ url_reasons: list[str] = []
133
+ for url in extract_urls(combined):
134
+ reason = _classify_url(url)
135
+ if reason:
136
+ suspicious_urls.append(url)
137
+ url_reasons.append(reason)
138
+
139
+ reasons: list[str] = []
140
+ if matched_keywords:
141
+ reasons.append(
142
+ f"Matched {len(matched_keywords)} phishing keyword(s): "
143
+ + ", ".join(matched_keywords[:3])
144
+ )
145
+ reasons.extend(url_reasons)
146
+
147
+ # Score: 0.35 per keyword hit + 0.4 per suspicious URL, capped at 1.0.
148
+ score = min(1.0, 0.35 * len(matched_keywords) + 0.4 * len(suspicious_urls))
149
+
150
+ return {
151
+ "is_spam": score >= self.SPAM_THRESHOLD,
152
+ "risk_score": round(score, 3),
153
+ "reasons": reasons,
154
+ "suspicious_urls": suspicious_urls,
155
+ "matched_keywords": matched_keywords,
156
+ }
deploy/monitoring/grafana_dashboard.json CHANGED
@@ -9,11 +9,13 @@
9
  },
10
  "enable": true,
11
  "hide": true,
 
12
  "name": "Annotations & Alerts",
13
  "type": "dashboard"
14
  }
15
  ]
16
  },
 
17
  "editable": true,
18
  "fiscalYearStartMonth": 0,
19
  "graphTooltip": 0,
@@ -22,152 +24,218 @@
22
  "liveNow": false,
23
  "panels": [
24
  {
25
- "collapsed": false,
26
- "gridPos": {
27
- "h": 8,
28
- "w": 12,
29
- "x": 0,
30
- "y": 0
31
- },
32
- "id": 1,
33
- "title": "Model Prediction Throughput (Requests / sec)",
34
- "type": "timeseries",
35
  "datasource": {
36
  "type": "prometheus",
37
- "uid": "prometheus"
38
  },
39
- "targets": [
40
- {
41
- "expr": "sum(rate(model_predictions_total[1m])) by (status)",
42
- "legendFormat": "Predictions: {{status}}",
43
- "refId": "A"
44
- }
45
- ],
46
  "fieldConfig": {
47
  "defaults": {
 
48
  "custom": {
 
49
  "drawStyle": "line",
50
- "lineInterpolation": "smooth"
 
 
51
  },
52
- "unit": "reqps"
53
- }
54
- }
55
- },
56
- {
57
- "collapsed": false,
58
- "gridPos": {
59
- "h": 8,
60
- "w": 12,
61
- "x": 12,
62
- "y": 0
63
  },
64
- "id": 2,
65
- "title": "DistilBERT Model Inference Latency",
66
- "type": "timeseries",
67
- "datasource": {
68
- "type": "prometheus",
69
- "uid": "prometheus"
70
  },
 
71
  "targets": [
72
  {
73
- "expr": "histogram_quantile(0.95, sum(rate(model_prediction_latency_seconds_bucket[5m])) by (le))",
74
- "legendFormat": "p95 Latency",
75
  "refId": "A"
76
  },
77
  {
78
- "expr": "histogram_quantile(0.99, sum(rate(model_prediction_latency_seconds_bucket[5m])) by (le))",
79
- "legendFormat": "p99 Latency",
80
  "refId": "B"
81
  },
82
  {
83
- "expr": "sum(rate(model_prediction_latency_seconds_sum[5m])) / sum(rate(model_prediction_latency_seconds_count[5m]))",
84
- "legendFormat": "Avg Latency",
85
  "refId": "C"
86
  }
87
  ],
 
 
 
 
 
 
 
88
  "fieldConfig": {
89
  "defaults": {
 
90
  "custom": {
 
91
  "drawStyle": "line",
92
- "lineInterpolation": "smooth"
 
 
93
  },
94
- "unit": "s"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  }
96
- }
 
97
  },
98
  {
99
- "collapsed": false,
100
- "gridPos": {
101
- "h": 8,
102
- "w": 12,
103
- "x": 0,
104
- "y": 8
105
- },
106
- "id": 3,
107
- "title": "Backend CPU Usage",
108
- "type": "timeseries",
109
  "datasource": {
110
  "type": "prometheus",
111
- "uid": "prometheus"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  },
 
 
 
 
 
 
 
113
  "targets": [
114
  {
115
- "expr": "rate(process_cpu_seconds_total[1m]) * 100",
116
- "legendFormat": "CPU Usage",
117
  "refId": "A"
118
  }
119
  ],
 
 
 
 
 
 
 
120
  "fieldConfig": {
121
  "defaults": {
 
122
  "custom": {
 
123
  "drawStyle": "line",
124
- "lineInterpolation": "smooth"
 
 
125
  },
126
- "unit": "percent"
127
- }
128
- }
129
- },
130
- {
131
- "collapsed": false,
132
- "gridPos": {
133
- "h": 8,
134
- "w": 12,
135
- "x": 12,
136
- "y": 8
137
  },
 
138
  "id": 4,
139
- "title": "Backend Memory Usage (Resident Set Size)",
140
- "type": "timeseries",
141
- "datasource": {
142
- "type": "prometheus",
143
- "uid": "prometheus"
144
  },
 
145
  "targets": [
146
  {
147
  "expr": "process_resident_memory_bytes",
148
- "legendFormat": "Memory RSS",
149
  "refId": "A"
150
  }
151
  ],
 
 
 
 
 
 
 
152
  "fieldConfig": {
153
  "defaults": {
 
154
  "custom": {
 
155
  "drawStyle": "line",
156
- "lineInterpolation": "smooth"
 
 
157
  },
158
- "unit": "bytes"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  }
160
- }
 
161
  }
162
  ],
163
- "schemaVersion": 36,
 
164
  "style": "dark",
165
- "tags": [
166
- "helpdesk",
167
- "monitoring",
168
- "ai"
169
- ],
170
- "title": "FastAPI AI Helpdesk Performance Telemetry",
171
- "timezone": "browser",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  "weekStart": ""
173
  }
 
9
  },
10
  "enable": true,
11
  "hide": true,
12
+ "iconColor": "rgba(0, 211, 255, 1)",
13
  "name": "Annotations & Alerts",
14
  "type": "dashboard"
15
  }
16
  ]
17
  },
18
+ "description": "HELPDESK.AI service telemetry — AI inference latency, API throughput, token counts, and host resources.",
19
  "editable": true,
20
  "fiscalYearStartMonth": 0,
21
  "graphTooltip": 0,
 
24
  "liveNow": false,
25
  "panels": [
26
  {
 
 
 
 
 
 
 
 
 
 
27
  "datasource": {
28
  "type": "prometheus",
29
+ "uid": "${DS_PROMETHEUS}"
30
  },
 
 
 
 
 
 
 
31
  "fieldConfig": {
32
  "defaults": {
33
+ "color": {"mode": "palette-classic"},
34
  "custom": {
35
+ "axisLabel": "seconds",
36
  "drawStyle": "line",
37
+ "fillOpacity": 10,
38
+ "lineWidth": 2,
39
+ "showPoints": "never"
40
  },
41
+ "unit": "s"
42
+ },
43
+ "overrides": []
 
 
 
 
 
 
 
 
44
  },
45
+ "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},
46
+ "id": 1,
47
+ "options": {
48
+ "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
49
+ "tooltip": {"mode": "multi"}
 
50
  },
51
+ "title": "DistilBERT Inference Latency (p50 / p95 / p99)",
52
  "targets": [
53
  {
54
+ "expr": "histogram_quantile(0.50, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
55
+ "legendFormat": "p50",
56
  "refId": "A"
57
  },
58
  {
59
+ "expr": "histogram_quantile(0.95, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
60
+ "legendFormat": "p95",
61
  "refId": "B"
62
  },
63
  {
64
+ "expr": "histogram_quantile(0.99, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
65
+ "legendFormat": "p99",
66
  "refId": "C"
67
  }
68
  ],
69
+ "type": "timeseries"
70
+ },
71
+ {
72
+ "datasource": {
73
+ "type": "prometheus",
74
+ "uid": "${DS_PROMETHEUS}"
75
+ },
76
  "fieldConfig": {
77
  "defaults": {
78
+ "color": {"mode": "palette-classic"},
79
  "custom": {
80
+ "axisLabel": "req/s",
81
  "drawStyle": "line",
82
+ "fillOpacity": 10,
83
+ "lineWidth": 2,
84
+ "showPoints": "never"
85
  },
86
+ "unit": "reqps"
87
+ },
88
+ "overrides": []
89
+ },
90
+ "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},
91
+ "id": 2,
92
+ "options": {
93
+ "legend": {"calcs": ["mean"], "displayMode": "table", "placement": "bottom"},
94
+ "tooltip": {"mode": "multi"}
95
+ },
96
+ "title": "Classifier Request Rate (by status)",
97
+ "targets": [
98
+ {
99
+ "expr": "sum by (status) (rate(ai_classifier_inference_requests_total[1m]))",
100
+ "legendFormat": "{{status}}",
101
+ "refId": "A"
102
  }
103
+ ],
104
+ "type": "timeseries"
105
  },
106
  {
 
 
 
 
 
 
 
 
 
 
107
  "datasource": {
108
  "type": "prometheus",
109
+ "uid": "${DS_PROMETHEUS}"
110
+ },
111
+ "fieldConfig": {
112
+ "defaults": {
113
+ "color": {"mode": "palette-classic"},
114
+ "custom": {
115
+ "axisLabel": "tokens/s",
116
+ "drawStyle": "line",
117
+ "fillOpacity": 10,
118
+ "lineWidth": 2,
119
+ "showPoints": "never"
120
+ },
121
+ "unit": "short"
122
+ },
123
+ "overrides": []
124
  },
125
+ "gridPos": {"h": 9, "w": 12, "x": 0, "y": 9},
126
+ "id": 3,
127
+ "options": {
128
+ "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
129
+ "tooltip": {"mode": "multi"}
130
+ },
131
+ "title": "Token Throughput (input tokens / sec)",
132
  "targets": [
133
  {
134
+ "expr": "sum by (model) (rate(ai_classifier_input_tokens_total[1m]))",
135
+ "legendFormat": "{{model}}",
136
  "refId": "A"
137
  }
138
  ],
139
+ "type": "timeseries"
140
+ },
141
+ {
142
+ "datasource": {
143
+ "type": "prometheus",
144
+ "uid": "${DS_PROMETHEUS}"
145
+ },
146
  "fieldConfig": {
147
  "defaults": {
148
+ "color": {"mode": "palette-classic"},
149
  "custom": {
150
+ "axisLabel": "bytes",
151
  "drawStyle": "line",
152
+ "fillOpacity": 10,
153
+ "lineWidth": 2,
154
+ "showPoints": "never"
155
  },
156
+ "unit": "decbytes"
157
+ },
158
+ "overrides": []
 
 
 
 
 
 
 
 
159
  },
160
+ "gridPos": {"h": 9, "w": 12, "x": 12, "y": 9},
161
  "id": 4,
162
+ "options": {
163
+ "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
164
+ "tooltip": {"mode": "multi"}
 
 
165
  },
166
+ "title": "Process Memory (RSS)",
167
  "targets": [
168
  {
169
  "expr": "process_resident_memory_bytes",
170
+ "legendFormat": "{{instance}}",
171
  "refId": "A"
172
  }
173
  ],
174
+ "type": "timeseries"
175
+ },
176
+ {
177
+ "datasource": {
178
+ "type": "prometheus",
179
+ "uid": "${DS_PROMETHEUS}"
180
+ },
181
  "fieldConfig": {
182
  "defaults": {
183
+ "color": {"mode": "palette-classic"},
184
  "custom": {
185
+ "axisLabel": "CPU seconds/sec",
186
  "drawStyle": "line",
187
+ "fillOpacity": 10,
188
+ "lineWidth": 2,
189
+ "showPoints": "never"
190
  },
191
+ "unit": "percentunit"
192
+ },
193
+ "overrides": []
194
+ },
195
+ "gridPos": {"h": 9, "w": 24, "x": 0, "y": 18},
196
+ "id": 5,
197
+ "options": {
198
+ "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
199
+ "tooltip": {"mode": "multi"}
200
+ },
201
+ "title": "Process CPU Usage",
202
+ "targets": [
203
+ {
204
+ "expr": "rate(process_cpu_seconds_total[1m])",
205
+ "legendFormat": "{{instance}}",
206
+ "refId": "A"
207
  }
208
+ ],
209
+ "type": "timeseries"
210
  }
211
  ],
212
+ "refresh": "10s",
213
+ "schemaVersion": 38,
214
  "style": "dark",
215
+ "tags": ["helpdesk-ai", "fastapi", "ai-inference", "prometheus"],
216
+ "templating": {
217
+ "list": [
218
+ {
219
+ "current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
220
+ "hide": 0,
221
+ "includeAll": false,
222
+ "label": "Datasource",
223
+ "multi": false,
224
+ "name": "DS_PROMETHEUS",
225
+ "options": [],
226
+ "query": "prometheus",
227
+ "refresh": 1,
228
+ "regex": "",
229
+ "skipUrlSync": false,
230
+ "type": "datasource"
231
+ }
232
+ ]
233
+ },
234
+ "time": {"from": "now-1h", "to": "now"},
235
+ "timepicker": {},
236
+ "timezone": "",
237
+ "title": "HELPDESK.AI — Service Telemetry",
238
+ "uid": "helpdesk-ai-telemetry",
239
+ "version": 1,
240
  "weekStart": ""
241
  }
requirements.txt CHANGED
@@ -22,4 +22,6 @@ pytest
22
  pytest-asyncio
23
  httpx
24
  cryptography>=42.0.0
 
 
25
 
 
22
  pytest-asyncio
23
  httpx
24
  cryptography>=42.0.0
25
+ websockets>=12.0
26
+ prometheus-client>=0.19.0
27