File size: 10,075 Bytes
60fbe12
9cd7dfb
60fbe12
e4a1d9d
 
 
60fbe12
 
 
8b18b3a
 
 
 
 
60fbe12
b873673
 
 
 
8b18b3a
60fbe12
9cd7dfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a08115
 
 
 
 
 
9cd7dfb
 
0a08115
9cd7dfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d1ee67
60fbe12
 
 
9cd7dfb
0a08115
 
 
 
 
9cd7dfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d1ee67
60fbe12
9cd7dfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60fbe12
 
 
 
 
 
 
 
 
 
 
 
8572ea1
60fbe12
 
 
 
 
 
 
 
 
 
4a1cc3f
 
9883061
4a1cc3f
 
 
 
 
 
 
9883061
4a1cc3f
60fbe12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a08115
13bc37b
 
 
 
60fbe12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
/**
 * API client with comprehensive error handling, timeouts, and retry logic.
 *
 * VITE_API_BASE — set in frontend/.env (local) or Vercel env vars (production).
 * Production example: VITE_API_BASE=https://your-space.hf.space/api
 * Default (local dev): http://127.0.0.1:8000/api
 *
 * All calls throw on non-ok responses so callers always catch errors.
 * scoreAudio derives extension from actual blob MIME type.
 *
 * Audio input:
 *   VITE_ENABLE_AUDIO_INPUT — manual override. If "false", audio is always off.
 *   If unset or "true", the frontend checks the backend /health endpoint at
 *   startup to determine if ASR is actually available.
 */
const API_BASE = (import.meta.env.VITE_API_BASE || "http://127.0.0.1:8000/api").replace(/\/$/, "");

export const AUDIO_INPUT_HINT =
  import.meta.env.VITE_AUDIO_INPUT_HINT ||
  "Audio mode uses Whisper for speech-to-text. Speak clearly for best results.";

// Request configuration
const REQUEST_TIMEOUT = 30000; // 30 seconds default timeout
const MAX_RETRIES = 2;
const RETRY_DELAY = 1000; // 1 second between retries
const RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504]; // Statuses that warrant retry

/**
 * Create an AbortController with timeout
 */
function createTimeoutController(timeoutMs = REQUEST_TIMEOUT) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
  return { controller, timeoutId };
}

/**
 * Determine if an error is retryable
 */
function isRetryableError(error, status) {
  // Network errors (no connection, DNS failure, etc.)
  if (error.name === 'TypeError' || error.name === 'AbortError') {
    return true;
  }
  // Server errors that might be transient
  if (status && RETRYABLE_STATUSES.includes(status)) {
    return true;
  }
  return false;
}

/**
 * Sleep helper for retry delays
 */
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function reqWithRetry(path, options = {}, attempt = 0) {
  const token = localStorage.getItem("firebaseToken");
  const headers = { 
    "Content-Type": "application/json", 
    ...(token ? { "Authorization": `Bearer ${token}` } : {}),
    ...(options.headers || {}) 
  };

  const { controller, timeoutId } = createTimeoutController(options.timeout);
  
  try {
    const res = await fetch(`${API_BASE}${path}`, {
      ...options,
      headers,
      signal: controller.signal,
    });
    
    clearTimeout(timeoutId);
    
    if (!res.ok) {
      const data = await res.json().catch(() => ({}));
      const error = new Error(data.detail || data.message || `API error ${res.status}`);
      error.status = res.status;
      error.data = data;
      
      // Check if we should retry
      if (attempt < MAX_RETRIES && isRetryableError(error, res.status)) {
        console.warn(`API request failed (attempt ${attempt + 1}), retrying...`, error.message);
        await sleep(RETRY_DELAY * (attempt + 1)); // Exponential backoff
        return reqWithRetry(path, options, attempt + 1);
      }
      
      throw error;
    }
    
    return res.json();
  } catch (error) {
    clearTimeout(timeoutId);
    
    // Handle abort/timeout specifically
    if (error.name === 'AbortError') {
      const timeoutError = new Error('Request timed out. Please check your connection and try again.');
      timeoutError.status = 408;
      timeoutError.isTimeout = true;
      
      if (attempt < MAX_RETRIES) {
        console.warn(`Request timeout (attempt ${attempt + 1}), retrying...`);
        await sleep(RETRY_DELAY * (attempt + 1));
        return reqWithRetry(path, options, attempt + 1);
      }
      
      throw timeoutError;
    }
    
    // Handle network errors with retry
    if (attempt < MAX_RETRIES && isRetryableError(error)) {
      console.warn(`Network error (attempt ${attempt + 1}), retrying...`, error.message);
      await sleep(RETRY_DELAY * (attempt + 1));
      return reqWithRetry(path, options, attempt + 1);
    }
    
    // Enhance network error message
    if (error.name === 'TypeError' && error.message.includes('fetch')) {
      error.message = 'Network error. Please check your internet connection and try again.';
      error.isNetworkError = true;
    }
    
    throw error;
  }
}

async function reqMultipartWithRetry(url, body, attempt = 0) {
  const token = localStorage.getItem("firebaseToken");
  const headers = { 
    ...(token ? { "Authorization": `Bearer ${token}` } : {})
  };

  const { controller, timeoutId } = createTimeoutController(60000); // Longer timeout for file uploads

  try {
    const res = await fetch(url, { 
      method: "POST", 
      body,
      headers,
      signal: controller.signal,
    });
    
    clearTimeout(timeoutId);
    
    if (!res.ok) {
      const data = await res.json().catch(() => ({}));
      const error = new Error(data.detail || data.message || `API error ${res.status}`);
      error.status = res.status;
      error.data = data;
      
      // Check if we should retry (but be more conservative with file uploads)
      if (attempt < MAX_RETRIES && isRetryableError(error, res.status) && res.status !== 413) {
        console.warn(`Multipart request failed (attempt ${attempt + 1}), retrying...`, error.message);
        await sleep(RETRY_DELAY * (attempt + 1));
        return reqMultipartWithRetry(url, body, attempt + 1);
      }
      
      throw error;
    }
    
    return res.json();
  } catch (error) {
    clearTimeout(timeoutId);
    
    if (error.name === 'AbortError') {
      const timeoutError = new Error('Upload timed out. The file may be too large or your connection is slow.');
      timeoutError.status = 408;
      timeoutError.isTimeout = true;
      throw timeoutError;
    }
    
    if (attempt < MAX_RETRIES && isRetryableError(error)) {
      console.warn(`Multipart network error (attempt ${attempt + 1}), retrying...`, error.message);
      await sleep(RETRY_DELAY * (attempt + 1));
      return reqMultipartWithRetry(url, body, attempt + 1);
    }
    
    if (error.name === 'TypeError' && error.message.includes('fetch')) {
      error.message = 'Network error during upload. Please check your connection and try again.';
      error.isNetworkError = true;
    }
    
    throw error;
  }
}

// Maintain backward compatibility with original function names
async function req(path, options = {}) {
  return reqWithRetry(path, options);
}

async function reqMultipart(url, body) {
  return reqMultipartWithRetry(url, body);
}

/**
 * Check if an error is a network error (for UI handling)
 */
export function isNetworkError(error) {
  return error?.isNetworkError === true || error?.isTimeout === true || 
         error?.name === 'TypeError' || error?.name === 'AbortError';
}

/**
 * Check if an error is retryable (for UI retry buttons)
 */
export function isRetryable(error) {
  return isNetworkError(error) || RETRYABLE_STATUSES.includes(error?.status);
}

/** Map a MIME type string to a file extension for audio blobs. */
function audioExtFromMime(mimeType) {
  if (!mimeType) return ".webm";
  if (mimeType.includes("mp4"))  return ".mp4";
  if (mimeType.includes("ogg"))  return ".ogg";
  if (mimeType.includes("wav"))  return ".wav";
  return ".webm";
}

export const api = {
  getHealth:     ()         => req("/health"),
  createSession: ()         => req("/session/create", { method: "POST" }),

  uploadResume: (sid, file) => {
    const fd = new FormData();
    fd.append("session_id", sid);
    fd.append("file", file);
    return reqMultipart(`${API_BASE}/upload/resume`, fd);
  },

  parseResume:         (sid)     => req(`/parse/resume/${sid}`,         { method: "POST" }),
  
  // New: Parse and extract for resume autofill
  parseAndExtract: (sid, file) => {
    const fd = new FormData();
    fd.append("session_id", sid);
    fd.append("resume", file);
    return reqMultipart(`${API_BASE}/parse-and-extract`, fd);
  },
  
  // New: Dynamic interview generation (company research + LLM questions)
  generateDynamicInterview: (sid) => req(`/interview/generate-dynamic/${sid}`, { method: "POST" }),
  
  setJobDescription:   (payload) => req("/session/job_description",     { method: "POST", body: JSON.stringify(payload) }),
  setCandidateProfile: (payload) => req("/session/candidate_profile",   { method: "POST", body: JSON.stringify(payload) }),
  generatePlan:        (sid)     => req(`/interview/plan/${sid}`,       { method: "POST" }),
  startInterview:      (sid)     => req(`/session/start_interview?session_id=${encodeURIComponent(sid)}`, { method: "POST" }),
  nextQuestion:        (sid)     => req(`/session/next_question?session_id=${encodeURIComponent(sid)}`,  { method: "POST" }),
  scoreText:           (payload) => req("/score/text",                  { method: "POST", body: JSON.stringify(payload) }),

  scoreAudio: (sid, qid, blob) => {
    // Use the correct extension based on the actual MIME type (Safari uses audio/mp4)
    const ext      = audioExtFromMime(blob.type);
    const filename = `answer${ext}`;
    const fd       = new FormData();
    fd.append("file", blob, filename);
    return reqMultipart(
      `${API_BASE}/answer/audio?session_id=${encodeURIComponent(sid)}&question_id=${encodeURIComponent(qid)}`,
      fd
    );
  },

  sendPosture:  (payload) => req("/posture/report",    { method: "POST", body: JSON.stringify(payload) }),
  logViolation: (payload) => req("/session/violation", { method: "POST", body: JSON.stringify(payload) }),
  aggregate:    (sid)     => req(`/aggregate/${sid}`,  { method: "POST" }),
  analytics:    (sid)     => req(`/analytics/${sid}`,  { method: "POST" }),
  decision:     (sid)     => req(`/decision/${sid}`,   { method: "POST" }),
  getReport:    (sid)     => req(`/report/${sid}`),
  getUserHistory: ()      => req("/user/history"),
  
  // Session state recovery and skip support
  getSessionStatus: (sid)  => req(`/session/status/${encodeURIComponent(sid)}`),
  skipQuestion: (sid, qid) => req(`/session/skip/${encodeURIComponent(sid)}?question_id=${encodeURIComponent(qid || "")}`, { method: "POST" }),
};