mohit kumar commited on
Commit
6ff4e7f
Β·
1 Parent(s): 4606519

Convert pipeline /api/run to background job + polling

Browse files
Files changed (2) hide show
  1. server.py +110 -21
  2. static/app.js +43 -8
server.py CHANGED
@@ -230,6 +230,58 @@ async def get_screening_history():
230
 
231
 
232
  # ── Pipeline ─────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
  @app.post("/api/run")
235
  async def run_pipeline(
@@ -238,14 +290,15 @@ async def run_pipeline(
238
  jd: str = Query(default=""),
239
  weights: str = Query(default=""),
240
  ):
 
 
 
 
241
  if not MAIN_PY.exists():
242
  raise HTTPException(status_code=404, detail="main.py not found")
243
 
244
  cmd = [sys.executable, str(MAIN_PY), "--top", str(top_n), "--explain", str(explain_n)]
245
  if jd:
246
- # Mirror jd_parser's title derivation so the substring filter in main.py
247
- # matches the parsed jd_title. e.g. "Jr. Accountant - JD.docx" must
248
- # become "Jr. Accountant" β€” not the raw "Jr. Accountant - JD" stem.
249
  stem = jd.rsplit(".", 1)[0]
250
  stem = stem.replace("_", " ").replace("-", " ")
251
  stem = re.sub(r"\s+[Jj][Dd]$", "", stem).strip()
@@ -254,29 +307,65 @@ async def run_pipeline(
254
  stem = re.sub(r"\s+", " ", stem).strip()
255
  cmd += ["--jd", stem]
256
  if weights:
257
- # Pass the weights JSON through verbatim; main.py validates it.
258
  cmd += ["--weights", weights]
259
 
260
- try:
261
- result = subprocess.run(
262
- cmd,
263
- capture_output=True,
264
- text=True,
265
- cwd=str(BASE),
266
- timeout=900, # 15 min β€” accommodates LLM rate-limit retries
267
- )
268
- import re as _re
269
- def _strip_ansi(s: str) -> str:
270
- return _re.sub(r'\x1b\[[0-9;]*m', '', s or '')
 
 
 
 
 
 
 
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  return {
273
- "success": result.returncode == 0,
274
- "returncode": result.returncode,
275
- "stdout": _strip_ansi(result.stdout[-3000:] if result.stdout else ""),
276
- "stderr": _strip_ansi(result.stderr[-2000:] if result.stderr else ""),
 
 
 
277
  }
278
- except subprocess.TimeoutExpired:
279
- raise HTTPException(status_code=408, detail="Pipeline timed out after 15 minutes")
280
 
281
 
282
  # ── Results & Candidates ─────────────────────────────────────────────────────
 
230
 
231
 
232
  # ── Pipeline ─────────────────────────────────────────────────────────────────
233
+ # The pipeline can take 2-15 minutes per run depending on resume count, LLM
234
+ # rate limits, and whether JobBERT needs warming. Holding the HTTP request
235
+ # open that long doesn't work behind HF Spaces' ~90s proxy timeout β€” the
236
+ # browser sees a disconnect while the subprocess keeps running silently.
237
+ #
238
+ # So we run main.py in a background thread, key it by a job UUID, and let
239
+ # the UI poll /api/run/status/{job_id} until the job reports completed.
240
+
241
+ import threading as _threading
242
+ import uuid as _uuid_pipeline
243
+ import time as _time
244
+
245
+ _PIPELINE_JOBS: dict = {} # job_id -> {status, result, started_at, finished_at, cmd}
246
+ _PIPELINE_LOCK = _threading.Lock()
247
+
248
+ def _strip_ansi(s: str) -> str:
249
+ return re.sub(r'\x1b\[[0-9;]*m', '', s or '')
250
+
251
+
252
+ def _run_pipeline_worker(job_id: str, cmd: list[str]) -> None:
253
+ """Background-thread worker that runs main.py and stores the result."""
254
+ with _PIPELINE_LOCK:
255
+ _PIPELINE_JOBS[job_id]["status"] = "running"
256
+ try:
257
+ result = subprocess.run(
258
+ cmd, capture_output=True, text=True,
259
+ cwd=str(BASE),
260
+ timeout=1500, # 25 min β€” large headroom for first-run JobBERT warm-up + LLM retries
261
+ )
262
+ with _PIPELINE_LOCK:
263
+ _PIPELINE_JOBS[job_id].update({
264
+ "status": "completed" if result.returncode == 0 else "failed",
265
+ "returncode": result.returncode,
266
+ "stdout": _strip_ansi(result.stdout[-3000:] if result.stdout else ""),
267
+ "stderr": _strip_ansi(result.stderr[-2000:] if result.stderr else ""),
268
+ "finished_at": _time.time(),
269
+ })
270
+ except subprocess.TimeoutExpired:
271
+ with _PIPELINE_LOCK:
272
+ _PIPELINE_JOBS[job_id].update({
273
+ "status": "timeout",
274
+ "stderr": "Pipeline exceeded 25-minute internal timeout.",
275
+ "finished_at": _time.time(),
276
+ })
277
+ except Exception as exc:
278
+ with _PIPELINE_LOCK:
279
+ _PIPELINE_JOBS[job_id].update({
280
+ "status": "error",
281
+ "stderr": f"Worker crashed: {type(exc).__name__}: {exc}",
282
+ "finished_at": _time.time(),
283
+ })
284
+
285
 
286
  @app.post("/api/run")
287
  async def run_pipeline(
 
290
  jd: str = Query(default=""),
291
  weights: str = Query(default=""),
292
  ):
293
+ """
294
+ Kick off the pipeline as a background job and return a job_id immediately
295
+ (within ~10ms). The UI polls /api/run/status/{job_id} for completion.
296
+ """
297
  if not MAIN_PY.exists():
298
  raise HTTPException(status_code=404, detail="main.py not found")
299
 
300
  cmd = [sys.executable, str(MAIN_PY), "--top", str(top_n), "--explain", str(explain_n)]
301
  if jd:
 
 
 
302
  stem = jd.rsplit(".", 1)[0]
303
  stem = stem.replace("_", " ").replace("-", " ")
304
  stem = re.sub(r"\s+[Jj][Dd]$", "", stem).strip()
 
307
  stem = re.sub(r"\s+", " ", stem).strip()
308
  cmd += ["--jd", stem]
309
  if weights:
 
310
  cmd += ["--weights", weights]
311
 
312
+ # Reject concurrent runs β€” main.py writes to a shared SQLite + CSVs;
313
+ # parallel runs would corrupt them.
314
+ with _PIPELINE_LOCK:
315
+ running = [jid for jid, j in _PIPELINE_JOBS.items()
316
+ if j.get("status") in ("queued", "running")]
317
+ if running:
318
+ raise HTTPException(status_code=409,
319
+ detail=f"Another pipeline is still running: {running[0]}")
320
+ job_id = str(_uuid_pipeline.uuid4())
321
+ _PIPELINE_JOBS[job_id] = {
322
+ "status": "queued",
323
+ "cmd": cmd,
324
+ "started_at": _time.time(),
325
+ }
326
+
327
+ _threading.Thread(target=_run_pipeline_worker,
328
+ args=(job_id, cmd), daemon=True).start()
329
+ return {"job_id": job_id, "status": "queued"}
330
 
331
+
332
+ @app.get("/api/run/status/{job_id}")
333
+ async def run_status(job_id: str):
334
+ """
335
+ Return the current status of a pipeline job. UI polls this every few
336
+ seconds. Returns 404 only for truly unknown IDs β€” otherwise always
337
+ returns a dict with at least `status`.
338
+ """
339
+ with _PIPELINE_LOCK:
340
+ job = _PIPELINE_JOBS.get(job_id)
341
+ if not job:
342
+ raise HTTPException(status_code=404, detail="Unknown job_id")
343
+
344
+ elapsed = _time.time() - job["started_at"]
345
+ return {
346
+ "job_id": job_id,
347
+ "status": job["status"],
348
+ "elapsed_s": round(elapsed, 1),
349
+ "success": job["status"] == "completed",
350
+ "returncode": job.get("returncode"),
351
+ "stdout": job.get("stdout", ""),
352
+ "stderr": job.get("stderr", ""),
353
+ }
354
+
355
+
356
+ @app.get("/api/run/jobs")
357
+ async def run_jobs():
358
+ """Debug: list every job and its current status. Useful when a UI lost its job_id."""
359
+ with _PIPELINE_LOCK:
360
  return {
361
+ "jobs": [
362
+ {"job_id": jid, "status": j["status"],
363
+ "elapsed_s": round(_time.time() - j["started_at"], 1)}
364
+ for jid, j in sorted(_PIPELINE_JOBS.items(),
365
+ key=lambda kv: kv[1].get("started_at", 0),
366
+ reverse=True)
367
+ ][:20]
368
  }
 
 
369
 
370
 
371
  # ── Results & Candidates ─────────────────────────────────────────────────────
static/app.js CHANGED
@@ -532,30 +532,65 @@ async function runPipeline() {
532
 
533
  btn.disabled = true;
534
  btn.innerHTML = '<span class="spinner"></span> Running…';
535
- showAlert('pipeline-status', 'info', '<span class="spinner"></span> Pipeline is running β€” please wait…');
536
  document.getElementById('pipeline-log-wrap').classList.add('hidden');
537
 
538
  let url = `/api/run?top_n=${topN}&explain_n=${expN}` +
539
  `&jd=${encodeURIComponent(jd)}` +
540
  `&weights=${encodeURIComponent(JSON.stringify(weightsPayload))}`;
541
 
542
- const data = await api(url, { method:'POST' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
  btn.disabled = false;
545
  btn.innerHTML = 'β–Ά&nbsp; Run Pipeline';
546
 
547
- if (!data) {
548
- showAlert('pipeline-status', 'error', 'βœ— Request failed β€” check server is running.');
549
- return;
550
- }
551
  if (data.success) {
552
  STATE.lastPipelineJd = jd ? jd.replace(/\.[^.]+$/, '') : '';
553
  showAlert('pipeline-status', 'success',
554
- 'βœ“ Pipeline completed successfully. <a href="#" onclick="switchPage(\'results\');return false;" ' +
 
555
  'style="color:var(--green);font-weight:600;text-decoration:underline">β†’ View Results</a>');
556
  setTimeout(() => switchPage('results'), 1500);
557
  } else {
558
- showAlert('pipeline-status', 'error', `βœ— Pipeline failed (exit code ${data.returncode})`);
 
 
 
 
 
559
  }
560
  if (data.stdout || data.stderr) {
561
  document.getElementById('pipeline-log-wrap').classList.remove('hidden');
 
532
 
533
  btn.disabled = true;
534
  btn.innerHTML = '<span class="spinner"></span> Running…';
535
+ showAlert('pipeline-status', 'info', '<span class="spinner"></span> Submitting pipeline…');
536
  document.getElementById('pipeline-log-wrap').classList.add('hidden');
537
 
538
  let url = `/api/run?top_n=${topN}&explain_n=${expN}` +
539
  `&jd=${encodeURIComponent(jd)}` +
540
  `&weights=${encodeURIComponent(JSON.stringify(weightsPayload))}`;
541
 
542
+ // Phase 1: enqueue the job (returns in ~10 ms with a job_id).
543
+ const start = await api(url, { method: 'POST' });
544
+
545
+ if (!start || !start.job_id) {
546
+ btn.disabled = false;
547
+ btn.innerHTML = 'β–Ά&nbsp; Run Pipeline';
548
+ showAlert('pipeline-status', 'error',
549
+ 'βœ— Could not start pipeline β€” check server logs.');
550
+ return;
551
+ }
552
+
553
+ const jobId = start.job_id;
554
+ showAlert('pipeline-status', 'info',
555
+ `<span class="spinner"></span> Pipeline running… (job ${jobId.slice(0, 8)}) ` +
556
+ `<span id="pipe-elapsed">0s</span>`);
557
+
558
+ // Phase 2: poll status every 3 seconds until it finishes or errors.
559
+ // This survives HF Spaces' ~90s HTTP proxy timeout because each poll is
560
+ // a fresh quick request.
561
+ let data = null;
562
+ while (true) {
563
+ await new Promise(r => setTimeout(r, 3000));
564
+ const status = await api(`/api/run/status/${jobId}`);
565
+ if (!status) continue; // transient network blip β€” try again
566
+
567
+ const el = document.getElementById('pipe-elapsed');
568
+ if (el) el.textContent = `${Math.round(status.elapsed_s)}s`;
569
+
570
+ if (status.status === 'completed' || status.status === 'failed' ||
571
+ status.status === 'error' || status.status === 'timeout') {
572
+ data = status;
573
+ break;
574
+ }
575
+ }
576
 
577
  btn.disabled = false;
578
  btn.innerHTML = 'β–Ά&nbsp; Run Pipeline';
579
 
 
 
 
 
580
  if (data.success) {
581
  STATE.lastPipelineJd = jd ? jd.replace(/\.[^.]+$/, '') : '';
582
  showAlert('pipeline-status', 'success',
583
+ `βœ“ Pipeline completed in ${Math.round(data.elapsed_s)}s. ` +
584
+ '<a href="#" onclick="switchPage(\'results\');return false;" ' +
585
  'style="color:var(--green);font-weight:600;text-decoration:underline">β†’ View Results</a>');
586
  setTimeout(() => switchPage('results'), 1500);
587
  } else {
588
+ const reason = data.status === 'timeout'
589
+ ? 'timed out'
590
+ : data.returncode != null
591
+ ? `failed (exit ${data.returncode})`
592
+ : `errored (${data.status})`;
593
+ showAlert('pipeline-status', 'error', `βœ— Pipeline ${reason}`);
594
  }
595
  if (data.stdout || data.stderr) {
596
  document.getElementById('pipeline-log-wrap').classList.remove('hidden');