feat: Upgrade dispatch.js proxy with retry logic, structured errors, health polling

#22
Files changed (1) hide show
  1. ops/backend-dm/routes/dispatch.js +140 -70
ops/backend-dm/routes/dispatch.js CHANGED
@@ -5,28 +5,79 @@ const router = express.Router();
5
  // Proxy to the FairRelay AI Brain (FastAPI)
6
  const BRAIN_URL = process.env.BRAIN_URL || 'http://localhost:8000';
7
 
8
- // Helper to proxy requests to the AI Brain
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  async function proxyToBrain(req, res, method, path, data = null) {
 
10
  try {
11
- const config = {
12
- method,
13
- url: `${BRAIN_URL}${path}`,
14
- headers: { 'Content-Type': 'application/json' },
15
- timeout: 30000,
16
- };
17
  if (data) config.data = data;
18
 
19
- const response = await axios(config);
 
 
 
 
20
  res.json(response.data);
21
  } catch (error) {
22
- console.error(`[Dispatch Proxy] Error proxying to brain: ${error.message}`);
 
 
23
  if (error.response) {
24
- res.status(error.response.status).json(error.response.data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  } else {
26
- res.status(503).json({
27
- error: 'AI Brain service unavailable',
 
28
  message: error.message,
29
- hint: 'Make sure the FastAPI brain is running on port 8000'
30
  });
31
  }
32
  }
@@ -36,9 +87,22 @@ async function proxyToBrain(req, res, method, path, data = null) {
36
 
37
  // Run fair allocation with LangGraph multi-agent workflow
38
  router.post('/allocate', async (req, res) => {
 
 
 
 
 
 
 
 
39
  await proxyToBrain(req, res, 'POST', '/api/v1/allocate/langgraph', req.body);
40
  });
41
 
 
 
 
 
 
42
  // Get allocation status for a run
43
  router.get('/runs/:runId', async (req, res) => {
44
  await proxyToBrain(req, res, 'GET', `/api/v1/runs/${req.params.runId}`);
@@ -55,6 +119,7 @@ router.get('/agent-events/stream', async (req, res) => {
55
  const response = await axios({
56
  method: 'GET',
57
  url: `${BRAIN_URL}/agent-events/stream`,
 
58
  responseType: 'stream',
59
  timeout: 0,
60
  });
@@ -62,6 +127,7 @@ router.get('/agent-events/stream', async (req, res) => {
62
  res.setHeader('Content-Type', 'text/event-stream');
63
  res.setHeader('Cache-Control', 'no-cache');
64
  res.setHeader('Connection', 'keep-alive');
 
65
 
66
  response.data.pipe(res);
67
 
@@ -69,8 +135,8 @@ router.get('/agent-events/stream', async (req, res) => {
69
  response.data.destroy();
70
  });
71
  } catch (error) {
72
- console.error('[Dispatch Proxy] SSE stream error:', error.message);
73
- res.status(503).json({ error: 'Agent events stream unavailable' });
74
  }
75
  });
76
 
@@ -93,117 +159,124 @@ router.post('/feedback', async (req, res) => {
93
  await proxyToBrain(req, res, 'POST', '/api/v1/feedback', req.body);
94
  });
95
 
96
- // Health check for brain
 
 
 
97
  router.get('/health', async (req, res) => {
 
 
 
 
 
98
  try {
99
- const response = await axios.get(`${BRAIN_URL}/health`, { timeout: 5000 });
100
- res.json({
101
  brain_status: 'connected',
102
  brain_health: response.data,
103
- gateway: 'operational'
104
- });
 
 
 
 
105
  } catch (error) {
106
- res.json({
107
  brain_status: 'disconnected',
108
  error: error.message,
109
  gateway: 'operational',
110
- hint: 'Start the AI Brain: cd brain && uvicorn app.main:app --port 8000'
111
- });
 
 
 
112
  }
113
  });
114
 
115
- // ====== NEW: Wellness-Aware Dispatch ======
116
  router.post('/wellness-check', async (req, res) => {
117
- // Wellness scoring for drivers before dispatch
118
  const { drivers } = req.body;
119
 
120
  const scoredDrivers = (drivers || []).map(driver => {
121
  const wrs = calculateWellnessScore(driver);
 
122
  return {
123
  ...driver,
124
  wellnessScore: wrs,
 
 
125
  maxDifficulty: wrs < 40 ? 'EASY' : wrs < 70 ? 'MEDIUM' : 'ANY',
126
  wellnessStatus: wrs < 40 ? 'FATIGUED' : wrs < 70 ? 'MODERATE' : 'FIT',
127
  };
128
  });
129
 
130
- res.json({ drivers: scoredDrivers });
131
  });
132
 
133
  function calculateWellnessScore(driver) {
134
- const {
135
- hoursToday = 0,
136
- hoursSinceRest = 24,
137
- isIll = false,
138
- totalHours7d = 0,
139
- } = driver;
140
-
141
- // Max 8 hrs/day, fatigue increases after that
142
  const fatigueFactor = Math.min(hoursToday / 12, 1.0) * 30;
143
-
144
- // Less rest = lower score
145
  const restFactor = Math.max(0, (1 - Math.min(hoursSinceRest / 10, 1.0))) * 25;
146
-
147
- // Illness flag
148
  const illnessFactor = isIll ? 30 : 0;
149
-
150
- // Weekly overwork (>50 hrs = concerning)
151
  const overworkFactor = Math.min(totalHours7d / 70, 1.0) * 15;
 
 
 
 
 
 
152
 
153
- const rawScore = 100 - fatigueFactor - restFactor - illnessFactor - overworkFactor;
154
- return Math.max(0, Math.min(100, Math.round(rawScore)));
 
 
 
 
 
 
 
 
 
155
  }
156
 
157
- // ====== NEW: Carbon Footprint Tracking ======
158
  router.post('/carbon-calculate', async (req, res) => {
159
  const { routes } = req.body;
160
- const EMISSION_FACTORS = {
161
- PETROL: 2.31,
162
- DIESEL: 2.68,
163
- CNG: 1.86,
164
- ELECTRIC: 0.0,
165
- EV: 0.0,
166
- };
167
 
168
  const results = (routes || []).map(route => {
169
  const factor = EMISSION_FACTORS[route.vehicleType] || EMISSION_FACTORS.DIESEL;
170
  const loadFactor = Math.min(route.loadPercent || 70, 100) / 100;
171
  const co2Kg = route.distanceKm * factor * (0.5 + 0.5 * loadFactor);
172
- const evOptimal = 0; // If EV was used
173
- const carbonSaved = co2Kg - evOptimal;
174
-
175
  return {
176
- routeId: route.routeId,
177
- distanceKm: route.distanceKm,
178
  vehicleType: route.vehicleType,
179
  co2ActualKg: Math.round(co2Kg * 100) / 100,
180
  co2OptimalKg: 0,
181
- carbonSavedKg: Math.round(carbonSaved * 100) / 100,
182
  greenScore: Math.round((1 - co2Kg / (route.distanceKm * 2.68 + 0.01)) * 100),
183
  };
184
  });
185
 
186
- const totalCO2 = results.reduce((sum, r) => sum + r.co2ActualKg, 0);
187
- const totalSaved = results.reduce((sum, r) => sum + r.carbonSavedKg, 0);
188
- const fleetGreenScore = results.length > 0
189
- ? Math.round(results.reduce((sum, r) => sum + r.greenScore, 0) / results.length)
190
- : 0;
191
 
192
  res.json({
193
  routes: results,
194
  summary: {
195
  totalCO2Kg: Math.round(totalCO2 * 100) / 100,
196
  totalCarbonSavedKg: Math.round(totalSaved * 100) / 100,
197
- fleetGreenScore,
198
  evUtilizationRate: results.filter(r => r.vehicleType === 'ELECTRIC' || r.vehicleType === 'EV').length / (results.length || 1) * 100,
199
  }
200
  });
201
  });
202
 
203
- // ====== NEW: Night Safety Filter ======
204
  router.post('/night-safety-filter', async (req, res) => {
205
  const { drivers, currentHour } = req.body;
206
- const isNight = (currentHour || new Date().getHours()) >= 19 || (currentHour || new Date().getHours()) <= 6;
 
207
 
208
  const filtered = (drivers || []).map(driver => {
209
  const needsSafety = isNight && driver.gender === 'F';
@@ -211,16 +284,13 @@ router.post('/night-safety-filter', async (req, res) => {
211
  ...driver,
212
  nightSafetyActive: needsSafety,
213
  routeConstraints: needsSafety ? {
214
- maxDistanceKm: 50,
215
- avoidHighCrimeZones: true,
216
- preferWellLitAreas: true,
217
- preferNearPoliceStations: true,
218
- sosEnabled: true,
219
  } : null,
220
  };
221
  });
222
 
223
- res.json({ drivers: filtered, isNightMode: isNight });
224
  });
225
 
226
  module.exports = router;
 
5
  // Proxy to the FairRelay AI Brain (FastAPI)
6
  const BRAIN_URL = process.env.BRAIN_URL || 'http://localhost:8000';
7
 
8
+ // Axios instance with retry logic
9
+ const brainClient = axios.create({
10
+ baseURL: BRAIN_URL,
11
+ timeout: 30000,
12
+ headers: { 'Content-Type': 'application/json' },
13
+ });
14
+
15
+ // Retry interceptor (3 retries with exponential backoff)
16
+ brainClient.interceptors.response.use(null, async (error) => {
17
+ const config = error.config;
18
+ if (!config || config.__retryCount >= 3) return Promise.reject(error);
19
+
20
+ config.__retryCount = (config.__retryCount || 0) + 1;
21
+ const backoff = Math.pow(2, config.__retryCount) * 500;
22
+
23
+ // Only retry on network errors or 5xx
24
+ if (!error.response || error.response.status >= 500) {
25
+ await new Promise(r => setTimeout(r, backoff));
26
+ return brainClient(config);
27
+ }
28
+ return Promise.reject(error);
29
+ });
30
+
31
+ // Helper to proxy requests to the AI Brain with structured error handling
32
  async function proxyToBrain(req, res, method, path, data = null) {
33
+ const startTime = Date.now();
34
  try {
35
+ const config = { method, url: path };
 
 
 
 
 
36
  if (data) config.data = data;
37
 
38
+ const response = await brainClient(config);
39
+ const latencyMs = Date.now() - startTime;
40
+
41
+ // Add latency header for monitoring
42
+ res.set('X-Brain-Latency-Ms', String(latencyMs));
43
  res.json(response.data);
44
  } catch (error) {
45
+ const latencyMs = Date.now() - startTime;
46
+ console.error(`[Dispatch] ${method} ${path} failed (${latencyMs}ms):`, error.message);
47
+
48
  if (error.response) {
49
+ // Brain returned an error — forward it with context
50
+ res.status(error.response.status).json({
51
+ success: false,
52
+ error: error.response.data?.detail || error.response.data?.error || 'Brain service error',
53
+ status: error.response.status,
54
+ brain_url: BRAIN_URL,
55
+ latency_ms: latencyMs,
56
+ path: path,
57
+ });
58
+ } else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
59
+ res.status(503).json({
60
+ success: false,
61
+ error: 'AI Brain service unreachable',
62
+ code: 'BRAIN_UNREACHABLE',
63
+ brain_url: BRAIN_URL,
64
+ hint: 'Brain service may be cold-starting on Render (30-60s). Retry shortly.',
65
+ latency_ms: latencyMs,
66
+ });
67
+ } else if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
68
+ res.status(504).json({
69
+ success: false,
70
+ error: 'Brain service timeout — allocation may be running with large dataset',
71
+ code: 'BRAIN_TIMEOUT',
72
+ latency_ms: latencyMs,
73
+ hint: 'Try again or reduce input size.',
74
+ });
75
  } else {
76
+ res.status(500).json({
77
+ success: false,
78
+ error: 'Unexpected error communicating with AI Brain',
79
  message: error.message,
80
+ latency_ms: latencyMs,
81
  });
82
  }
83
  }
 
87
 
88
  // Run fair allocation with LangGraph multi-agent workflow
89
  router.post('/allocate', async (req, res) => {
90
+ // Validate minimum input
91
+ if (!req.body.drivers?.length && !req.body.packages?.length) {
92
+ return res.status(400).json({
93
+ success: false,
94
+ error: 'Request must include drivers and/or packages arrays',
95
+ hint: 'See API docs at /api/docs',
96
+ });
97
+ }
98
  await proxyToBrain(req, res, 'POST', '/api/v1/allocate/langgraph', req.body);
99
  });
100
 
101
+ // Fallback: original (non-LangGraph) allocation
102
+ router.post('/allocate/simple', async (req, res) => {
103
+ await proxyToBrain(req, res, 'POST', '/api/v1/allocate', req.body);
104
+ });
105
+
106
  // Get allocation status for a run
107
  router.get('/runs/:runId', async (req, res) => {
108
  await proxyToBrain(req, res, 'GET', `/api/v1/runs/${req.params.runId}`);
 
119
  const response = await axios({
120
  method: 'GET',
121
  url: `${BRAIN_URL}/agent-events/stream`,
122
+ params: req.query,
123
  responseType: 'stream',
124
  timeout: 0,
125
  });
 
127
  res.setHeader('Content-Type', 'text/event-stream');
128
  res.setHeader('Cache-Control', 'no-cache');
129
  res.setHeader('Connection', 'keep-alive');
130
+ res.setHeader('X-Accel-Buffering', 'no');
131
 
132
  response.data.pipe(res);
133
 
 
135
  response.data.destroy();
136
  });
137
  } catch (error) {
138
+ console.error('[Dispatch] SSE stream error:', error.message);
139
+ res.status(503).json({ error: 'Agent events stream unavailable', hint: 'Brain may be starting up' });
140
  }
141
  });
142
 
 
159
  await proxyToBrain(req, res, 'POST', '/api/v1/feedback', req.body);
160
  });
161
 
162
+ // Health check for brain with caching (avoid hammering brain)
163
+ let _brainHealthCache = { data: null, ts: 0 };
164
+ const HEALTH_CACHE_TTL = 15000; // 15s
165
+
166
  router.get('/health', async (req, res) => {
167
+ const now = Date.now();
168
+ if (_brainHealthCache.data && (now - _brainHealthCache.ts) < HEALTH_CACHE_TTL) {
169
+ return res.json(_brainHealthCache.data);
170
+ }
171
+
172
  try {
173
+ const response = await brainClient.get('/health', { timeout: 5000 });
174
+ const result = {
175
  brain_status: 'connected',
176
  brain_health: response.data,
177
+ gateway: 'operational',
178
+ brain_url: BRAIN_URL,
179
+ latency_ms: Date.now() - now,
180
+ };
181
+ _brainHealthCache = { data: result, ts: now };
182
+ res.json(result);
183
  } catch (error) {
184
+ const result = {
185
  brain_status: 'disconnected',
186
  error: error.message,
187
  gateway: 'operational',
188
+ brain_url: BRAIN_URL,
189
+ hint: 'Brain may be cold-starting (30-60s on free tier). Try again shortly.',
190
+ };
191
+ _brainHealthCache = { data: result, ts: now };
192
+ res.json(result);
193
  }
194
  });
195
 
196
+ // ====== Wellness-Aware Dispatch ======
197
  router.post('/wellness-check', async (req, res) => {
 
198
  const { drivers } = req.body;
199
 
200
  const scoredDrivers = (drivers || []).map(driver => {
201
  const wrs = calculateWellnessScore(driver);
202
+ const cli = calculateCognitiveLoad(driver);
203
  return {
204
  ...driver,
205
  wellnessScore: wrs,
206
+ cognitiveLoad: cli.score,
207
+ cognitiveState: cli.state,
208
  maxDifficulty: wrs < 40 ? 'EASY' : wrs < 70 ? 'MEDIUM' : 'ANY',
209
  wellnessStatus: wrs < 40 ? 'FATIGUED' : wrs < 70 ? 'MODERATE' : 'FIT',
210
  };
211
  });
212
 
213
+ res.json({ drivers: scoredDrivers, timestamp: new Date().toISOString() });
214
  });
215
 
216
  function calculateWellnessScore(driver) {
217
+ const { hoursToday = 0, hoursSinceRest = 24, isIll = false, totalHours7d = 0 } = driver;
 
 
 
 
 
 
 
218
  const fatigueFactor = Math.min(hoursToday / 12, 1.0) * 30;
 
 
219
  const restFactor = Math.max(0, (1 - Math.min(hoursSinceRest / 10, 1.0))) * 25;
 
 
220
  const illnessFactor = isIll ? 30 : 0;
 
 
221
  const overworkFactor = Math.min(totalHours7d / 70, 1.0) * 15;
222
+ return Math.max(0, Math.min(100, Math.round(100 - fatigueFactor - restFactor - illnessFactor - overworkFactor)));
223
+ }
224
+
225
+ function calculateCognitiveLoad(driver) {
226
+ const { stopsToday = 0, hoursToday = 0, cognitiveLoad } = driver;
227
+ if (cognitiveLoad !== undefined) return { score: cognitiveLoad, state: cognitiveLoad > 70 ? 'OVERLOADED' : cognitiveLoad > 50 ? 'STRAINED' : cognitiveLoad > 30 ? 'ALERT' : 'SHARP' };
228
 
229
+ // 6-factor Cognitive Load Index (CLI)
230
+ const decisionFatigue = Math.min(stopsToday / 20, 1.0) * 25;
231
+ const timePressure = Math.min(hoursToday / 10, 1.0) * 20;
232
+ const taskComplexity = Math.min(stopsToday * 2.5, 25);
233
+ const circadianDip = (new Date().getHours() >= 13 && new Date().getHours() <= 15) ? 10 : 0;
234
+ const monotony = hoursToday > 6 ? 10 : 0;
235
+ const environmentalStress = 5; // baseline
236
+
237
+ const score = Math.min(100, Math.round(decisionFatigue + timePressure + taskComplexity + circadianDip + monotony + environmentalStress));
238
+ const state = score > 70 ? 'OVERLOADED' : score > 50 ? 'STRAINED' : score > 30 ? 'ALERT' : 'SHARP';
239
+ return { score, state };
240
  }
241
 
242
+ // ====== Carbon Footprint Tracking ======
243
  router.post('/carbon-calculate', async (req, res) => {
244
  const { routes } = req.body;
245
+ const EMISSION_FACTORS = { PETROL: 2.31, DIESEL: 2.68, CNG: 1.86, ELECTRIC: 0.0, EV: 0.0 };
 
 
 
 
 
 
246
 
247
  const results = (routes || []).map(route => {
248
  const factor = EMISSION_FACTORS[route.vehicleType] || EMISSION_FACTORS.DIESEL;
249
  const loadFactor = Math.min(route.loadPercent || 70, 100) / 100;
250
  const co2Kg = route.distanceKm * factor * (0.5 + 0.5 * loadFactor);
 
 
 
251
  return {
252
+ routeId: route.routeId, distanceKm: route.distanceKm,
 
253
  vehicleType: route.vehicleType,
254
  co2ActualKg: Math.round(co2Kg * 100) / 100,
255
  co2OptimalKg: 0,
256
+ carbonSavedKg: Math.round(co2Kg * 100) / 100,
257
  greenScore: Math.round((1 - co2Kg / (route.distanceKm * 2.68 + 0.01)) * 100),
258
  };
259
  });
260
 
261
+ const totalCO2 = results.reduce((s, r) => s + r.co2ActualKg, 0);
262
+ const totalSaved = results.reduce((s, r) => s + r.carbonSavedKg, 0);
 
 
 
263
 
264
  res.json({
265
  routes: results,
266
  summary: {
267
  totalCO2Kg: Math.round(totalCO2 * 100) / 100,
268
  totalCarbonSavedKg: Math.round(totalSaved * 100) / 100,
269
+ fleetGreenScore: results.length > 0 ? Math.round(results.reduce((s, r) => s + r.greenScore, 0) / results.length) : 0,
270
  evUtilizationRate: results.filter(r => r.vehicleType === 'ELECTRIC' || r.vehicleType === 'EV').length / (results.length || 1) * 100,
271
  }
272
  });
273
  });
274
 
275
+ // ====== Night Safety Filter ======
276
  router.post('/night-safety-filter', async (req, res) => {
277
  const { drivers, currentHour } = req.body;
278
+ const hour = currentHour ?? new Date().getHours();
279
+ const isNight = hour >= 19 || hour <= 6;
280
 
281
  const filtered = (drivers || []).map(driver => {
282
  const needsSafety = isNight && driver.gender === 'F';
 
284
  ...driver,
285
  nightSafetyActive: needsSafety,
286
  routeConstraints: needsSafety ? {
287
+ maxDistanceKm: 50, avoidHighCrimeZones: true,
288
+ preferWellLitAreas: true, preferNearPoliceStations: true, sosEnabled: true,
 
 
 
289
  } : null,
290
  };
291
  });
292
 
293
+ res.json({ drivers: filtered, isNightMode: isNight, hour });
294
  });
295
 
296
  module.exports = router;