Spaces:
Sleeping
Sleeping
mohit kumar commited on
Commit Β·
6ff4e7f
1
Parent(s): 4606519
Convert pipeline /api/run to background job + polling
Browse files- server.py +110 -21
- 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 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
return {
|
| 273 |
-
"
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
|
| 544 |
btn.disabled = false;
|
| 545 |
btn.innerHTML = 'βΆ 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 |
-
|
|
|
|
| 555 |
'style="color:var(--green);font-weight:600;text-decoration:underline">β View Results</a>');
|
| 556 |
setTimeout(() => switchPage('results'), 1500);
|
| 557 |
} else {
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = 'βΆ 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 = 'βΆ 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');
|