Spaces:
Sleeping
feat: V1 noise fix, real progress animation via SSE
Browse filesV1 noise fix (parity with V2):
- optimize_latex_resume: add company param + progress_callback + filter_scraped_noise on includable pool
- latex_flow_for_api: pass company down so noise filter can screen the company name
Progress animation (real stage labels):
- api_server.py: new /api/generate-stream SSE endpoint; progress_callback bridges sync
flow stages to async SSE events (stage/pct per event, final event carries full result)
- generate_v2: progress_callback param + stage calls at keyword extract, rank, fan-out, compile
- background.js: SSE streaming path in runGenerate reads stage events and writes them to
storage so the popup updates live; falls back to blocking /api/generate if stream fails
- popup.js: stage label from storage shown in spinner instead of generic "Generating resume..."
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- api_server.py +133 -1
- extension/background.js +80 -20
- extension/popup/popup.js +4 -2
- src/latex_resume.py +15 -2
- src/resume_v2_natural.py +13 -0
|
@@ -29,7 +29,8 @@ import httpx
|
|
| 29 |
import uvicorn
|
| 30 |
from fastapi import FastAPI, Header, HTTPException, UploadFile, Form, Request, WebSocket, WebSocketDisconnect, BackgroundTasks
|
| 31 |
from fastapi.middleware.cors import CORSMiddleware
|
| 32 |
-
|
|
|
|
| 33 |
from starlette.responses import Response as StarletteResponse
|
| 34 |
|
| 35 |
# ── App init ──────────────────────────────────────────────────────────────────
|
|
@@ -214,6 +215,7 @@ def latex_flow_for_api(
|
|
| 214 |
compile_pdf=True,
|
| 215 |
out_dir=out_dir,
|
| 216 |
job_title=company or job_title or "resume",
|
|
|
|
| 217 |
)
|
| 218 |
return report, out_dir
|
| 219 |
|
|
@@ -453,6 +455,136 @@ async def _generate_from_latex_v2(
|
|
| 453 |
shutil.rmtree(out_dir, ignore_errors=True)
|
| 454 |
|
| 455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
async def _repair_from_latex(
|
| 457 |
latex_src: str, jd_text: str, job_title: str, company: str,
|
| 458 |
max_ats: bool, conf_terms: list, pasted_terms: list,
|
|
|
|
| 29 |
import uvicorn
|
| 30 |
from fastapi import FastAPI, Header, HTTPException, UploadFile, Form, Request, WebSocket, WebSocketDisconnect, BackgroundTasks
|
| 31 |
from fastapi.middleware.cors import CORSMiddleware
|
| 32 |
+
import threading
|
| 33 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 34 |
from starlette.responses import Response as StarletteResponse
|
| 35 |
|
| 36 |
# ── App init ──────────────────────────────────────────────────────────────────
|
|
|
|
| 215 |
compile_pdf=True,
|
| 216 |
out_dir=out_dir,
|
| 217 |
job_title=company or job_title or "resume",
|
| 218 |
+
company=company or "",
|
| 219 |
)
|
| 220 |
return report, out_dir
|
| 221 |
|
|
|
|
| 455 |
shutil.rmtree(out_dir, ignore_errors=True)
|
| 456 |
|
| 457 |
|
| 458 |
+
@app.post("/api/generate-stream")
|
| 459 |
+
async def generate_stream_endpoint(
|
| 460 |
+
jd_text: str = Form(""),
|
| 461 |
+
job_title: str = Form(""),
|
| 462 |
+
company: str = Form(""),
|
| 463 |
+
maximum_ats_mode: str = Form(""),
|
| 464 |
+
confirmed_terms: str = Form(""),
|
| 465 |
+
resume_latex: str = Form(""),
|
| 466 |
+
version: str = Form(""),
|
| 467 |
+
resume: UploadFile = None,
|
| 468 |
+
x_api_token: str = Header(None),
|
| 469 |
+
):
|
| 470 |
+
"""SSE endpoint — same as /api/generate but streams progress events.
|
| 471 |
+
|
| 472 |
+
Events: `data: {"stage": "...", "pct": N}\\n\\n`
|
| 473 |
+
Final: `data: {"done": true, ...full result fields...}\\n\\n`
|
| 474 |
+
Error: `data: {"error": "...", "detail": "..."}\\n\\n`
|
| 475 |
+
"""
|
| 476 |
+
_check_token(x_api_token)
|
| 477 |
+
|
| 478 |
+
max_ats = _truthy(maximum_ats_mode)
|
| 479 |
+
conf = _term_list(confirmed_terms)
|
| 480 |
+
_version = (version or "").strip().lower() or os.getenv("GEN_VERSION_DEFAULT", "v2")
|
| 481 |
+
|
| 482 |
+
if not (resume_latex or "").strip() and resume is None:
|
| 483 |
+
try:
|
| 484 |
+
from src.default_resume import get_default_resume_latex
|
| 485 |
+
resume_latex = get_default_resume_latex()
|
| 486 |
+
except Exception:
|
| 487 |
+
pass
|
| 488 |
+
|
| 489 |
+
pdf_bytes = None
|
| 490 |
+
if resume is not None:
|
| 491 |
+
pdf_bytes = await resume.read()
|
| 492 |
+
|
| 493 |
+
if not (resume_latex or "").strip() and not pdf_bytes:
|
| 494 |
+
async def _err():
|
| 495 |
+
yield f'data: {json.dumps({"error": "no_resume", "detail": "No resume provided."})}\n\n'
|
| 496 |
+
return StreamingResponse(_err(), media_type="text/event-stream")
|
| 497 |
+
|
| 498 |
+
loop = asyncio.get_event_loop()
|
| 499 |
+
queue: asyncio.Queue = asyncio.Queue()
|
| 500 |
+
|
| 501 |
+
def _progress(stage: str, pct: int):
|
| 502 |
+
loop.call_soon_threadsafe(queue.put_nowait, {"stage": stage, "pct": pct})
|
| 503 |
+
|
| 504 |
+
def _run():
|
| 505 |
+
out_dir = None
|
| 506 |
+
try:
|
| 507 |
+
latex_src = (resume_latex or "").strip()
|
| 508 |
+
if _version == "v2":
|
| 509 |
+
from src.resume_v2_natural import generate_v2
|
| 510 |
+
out_dir = tempfile.mkdtemp(prefix="stream_v2_")
|
| 511 |
+
report = generate_v2(
|
| 512 |
+
latex_src, jd_text, job_title=job_title, company=company,
|
| 513 |
+
out_dir=out_dir, compile_pdf=True, progress_callback=_progress,
|
| 514 |
+
)
|
| 515 |
+
else:
|
| 516 |
+
from src.latex_resume import optimize_latex_resume
|
| 517 |
+
out_dir = tempfile.mkdtemp(prefix="stream_v1_")
|
| 518 |
+
from src.candidate_vault import user_blocked_terms
|
| 519 |
+
try:
|
| 520 |
+
blocked = list(user_blocked_terms())
|
| 521 |
+
except Exception:
|
| 522 |
+
blocked = []
|
| 523 |
+
report = optimize_latex_resume(
|
| 524 |
+
latex_src, jd_text,
|
| 525 |
+
maximum_ats_mode=max_ats,
|
| 526 |
+
confirmed_terms=conf,
|
| 527 |
+
blocked_terms=blocked,
|
| 528 |
+
compile_pdf=True,
|
| 529 |
+
out_dir=out_dir,
|
| 530 |
+
job_title=company or job_title or "resume",
|
| 531 |
+
company=company or "",
|
| 532 |
+
progress_callback=_progress,
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
# Build the same response payload as the blocking endpoints.
|
| 536 |
+
pct = int(report.get("pct", 0) or 0)
|
| 537 |
+
from src.fit_gate import MAX_ATS_READY_STATUSES
|
| 538 |
+
status = _latex_status(pct, max_ats, False)
|
| 539 |
+
download_allowed = status in MAX_ATS_READY_STATUSES
|
| 540 |
+
|
| 541 |
+
tex = report.get("tex") or latex_src
|
| 542 |
+
tex_b64 = base64.b64encode((tex or "").encode()).decode() if tex else None
|
| 543 |
+
pdf_b64 = None
|
| 544 |
+
pdf_path = report.get("pdf_path")
|
| 545 |
+
if pdf_path and os.path.exists(pdf_path):
|
| 546 |
+
with open(pdf_path, "rb") as _f:
|
| 547 |
+
pdf_b64 = base64.b64encode(_f.read()).decode()
|
| 548 |
+
|
| 549 |
+
payload = {
|
| 550 |
+
"done": True,
|
| 551 |
+
"status": status,
|
| 552 |
+
"source": f"latex_{_version}",
|
| 553 |
+
"version": _version,
|
| 554 |
+
"download_allowed": bool(download_allowed),
|
| 555 |
+
"maximum_ats_mode": max_ats,
|
| 556 |
+
"external_coverage_pct": pct,
|
| 557 |
+
"injected_terms": report.get("injected", []),
|
| 558 |
+
"judge_note": report.get("judge_note", ""),
|
| 559 |
+
"tex_b64": tex_b64,
|
| 560 |
+
"pdf_b64": pdf_b64,
|
| 561 |
+
"docx_b64": None,
|
| 562 |
+
}
|
| 563 |
+
loop.call_soon_threadsafe(queue.put_nowait, payload)
|
| 564 |
+
except Exception as exc:
|
| 565 |
+
loop.call_soon_threadsafe(
|
| 566 |
+
queue.put_nowait, {"error": "generation_failed", "detail": str(exc)[:300]})
|
| 567 |
+
finally:
|
| 568 |
+
if out_dir:
|
| 569 |
+
shutil.rmtree(out_dir, ignore_errors=True)
|
| 570 |
+
|
| 571 |
+
t = threading.Thread(target=_run, daemon=True)
|
| 572 |
+
t.start()
|
| 573 |
+
|
| 574 |
+
async def _events():
|
| 575 |
+
while True:
|
| 576 |
+
evt = await queue.get()
|
| 577 |
+
yield f"data: {json.dumps(evt)}\n\n"
|
| 578 |
+
if evt.get("done") or evt.get("error"):
|
| 579 |
+
break
|
| 580 |
+
|
| 581 |
+
return StreamingResponse(
|
| 582 |
+
_events(),
|
| 583 |
+
media_type="text/event-stream",
|
| 584 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
async def _repair_from_latex(
|
| 589 |
latex_src: str, jd_text: str, job_title: str, company: str,
|
| 590 |
max_ats: bool, conf_terms: list, pasted_terms: list,
|
|
@@ -441,11 +441,14 @@ async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, c
|
|
| 441 |
company: company || '',
|
| 442 |
});
|
| 443 |
|
|
|
|
|
|
|
|
|
|
| 444 |
// 3. Perform the network round-trip (no popup dependency).
|
| 445 |
const result = await runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version });
|
| 446 |
|
| 447 |
-
// 4. Overwrite the marker with the terminal state — done OR error
|
| 448 |
-
|
| 449 |
if (result && result.error) {
|
| 450 |
await writeEntry(urlKey, { status: 'error', result });
|
| 451 |
} else {
|
|
@@ -480,9 +483,8 @@ function stopKeepAlive() {
|
|
| 480 |
}
|
| 481 |
}
|
| 482 |
|
| 483 |
-
//
|
| 484 |
-
|
| 485 |
-
// Build multipart/form-data. LaTeX takes priority over the PDF.
|
| 486 |
const formData = new FormData();
|
| 487 |
formData.append('jd_text', jd_text);
|
| 488 |
formData.append('job_title', job_title || '');
|
|
@@ -496,21 +498,67 @@ async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, co
|
|
| 496 |
} else {
|
| 497 |
const binaryStr = atob(data.resume_b64);
|
| 498 |
const bytes = new Uint8Array(binaryStr.length);
|
| 499 |
-
for (let i = 0; i < binaryStr.length; i++)
|
| 500 |
-
|
| 501 |
-
}
|
| 502 |
-
const resumeBlob = new Blob([bytes], { type: 'application/pdf' });
|
| 503 |
-
formData.append('resume', resumeBlob, 'resume.pdf');
|
| 504 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
|
| 506 |
startKeepAlive();
|
| 507 |
try {
|
|
|
|
| 508 |
let resp;
|
| 509 |
try {
|
| 510 |
-
resp = await
|
| 511 |
-
method: 'POST',
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
});
|
| 515 |
} catch (networkErr) {
|
| 516 |
return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` };
|
|
@@ -521,19 +569,31 @@ async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, co
|
|
| 521 |
result = await resp.json();
|
| 522 |
} catch {
|
| 523 |
if (resp.status === 503) return { error: 'space_waking', detail: 'Space is waking up — wait ~30 s and try again.' };
|
| 524 |
-
|
| 525 |
-
}
|
| 526 |
-
|
| 527 |
-
if (!resp.ok) {
|
| 528 |
-
return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` };
|
| 529 |
}
|
| 530 |
-
|
| 531 |
return result;
|
| 532 |
} finally {
|
| 533 |
stopKeepAlive();
|
| 534 |
}
|
| 535 |
}
|
| 536 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
// ─── DOWNLOAD ────────────────────────────────────────────────────────────────
|
| 538 |
|
| 539 |
async function handleDownload({ format, b64, filename }) {
|
|
|
|
| 441 |
company: company || '',
|
| 442 |
});
|
| 443 |
|
| 444 |
+
// Expose the current key so _writeProgressStage can update the stage label.
|
| 445 |
+
_currentProgressKey = urlKey;
|
| 446 |
+
|
| 447 |
// 3. Perform the network round-trip (no popup dependency).
|
| 448 |
const result = await runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version });
|
| 449 |
|
| 450 |
+
// 4. Overwrite the marker with the terminal state — done OR error.
|
| 451 |
+
_currentProgressKey = null;
|
| 452 |
if (result && result.error) {
|
| 453 |
await writeEntry(urlKey, { status: 'error', result });
|
| 454 |
} else {
|
|
|
|
| 483 |
}
|
| 484 |
}
|
| 485 |
|
| 486 |
+
// Build the multipart FormData used by both blocking and streaming generate calls.
|
| 487 |
+
function _buildGenerateForm({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version }) {
|
|
|
|
| 488 |
const formData = new FormData();
|
| 489 |
formData.append('jd_text', jd_text);
|
| 490 |
formData.append('job_title', job_title || '');
|
|
|
|
| 498 |
} else {
|
| 499 |
const binaryStr = atob(data.resume_b64);
|
| 500 |
const bytes = new Uint8Array(binaryStr.length);
|
| 501 |
+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
| 502 |
+
formData.append('resume', new Blob([bytes], { type: 'application/pdf' }), 'resume.pdf');
|
|
|
|
|
|
|
|
|
|
| 503 |
}
|
| 504 |
+
return formData;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
// Network/parse layer for GENERATE — uses SSE streaming so the popup receives
|
| 508 |
+
// real stage labels (Analyzing JD → Placing keywords → Compiling PDF) as they
|
| 509 |
+
// happen. Falls back to the blocking endpoint if streaming isn't available.
|
| 510 |
+
async function runGenerate(params) {
|
| 511 |
+
const { data } = params;
|
| 512 |
+
const base = data.api_url.replace(/\/$/, '');
|
| 513 |
+
const headers = { 'X-Api-Token': data.api_token };
|
| 514 |
|
| 515 |
startKeepAlive();
|
| 516 |
try {
|
| 517 |
+
// ── SSE streaming path ────────────────────────────────────────────────────
|
| 518 |
let resp;
|
| 519 |
try {
|
| 520 |
+
resp = await fetch(`${base}/api/generate-stream`, {
|
| 521 |
+
method: 'POST', headers, body: _buildGenerateForm(params),
|
| 522 |
+
});
|
| 523 |
+
} catch (_) {
|
| 524 |
+
resp = null;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
// If the streaming endpoint exists and responded, read SSE events.
|
| 528 |
+
if (resp && resp.ok && resp.body) {
|
| 529 |
+
const reader = resp.body.getReader();
|
| 530 |
+
const decoder = new TextDecoder();
|
| 531 |
+
let buf = '';
|
| 532 |
+
while (true) {
|
| 533 |
+
const { done, value } = await reader.read();
|
| 534 |
+
if (done) break;
|
| 535 |
+
buf += decoder.decode(value, { stream: true });
|
| 536 |
+
const lines = buf.split('\n');
|
| 537 |
+
buf = lines.pop(); // keep the incomplete last line
|
| 538 |
+
for (const line of lines) {
|
| 539 |
+
if (!line.startsWith('data: ')) continue;
|
| 540 |
+
let evt;
|
| 541 |
+
try { evt = JSON.parse(line.slice(6)); } catch { continue; }
|
| 542 |
+
if (evt.stage) {
|
| 543 |
+
// Write progress stage to storage — popup's onChanged listener shows it.
|
| 544 |
+
await _writeProgressStage(evt.stage);
|
| 545 |
+
}
|
| 546 |
+
if (evt.done) {
|
| 547 |
+
const { done: _d, ...result } = evt;
|
| 548 |
+
return result;
|
| 549 |
+
}
|
| 550 |
+
if (evt.error) {
|
| 551 |
+
return { error: evt.error, detail: evt.detail };
|
| 552 |
+
}
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
return { error: 'stream_ended', detail: 'SSE stream ended without a result.' };
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
// ── Fallback: blocking /api/generate ─────────────────────────────────────
|
| 559 |
+
try {
|
| 560 |
+
resp = await _fetchWithRetry(`${base}/api/generate`, {
|
| 561 |
+
method: 'POST', headers, body: _buildGenerateForm(params),
|
| 562 |
});
|
| 563 |
} catch (networkErr) {
|
| 564 |
return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` };
|
|
|
|
| 569 |
result = await resp.json();
|
| 570 |
} catch {
|
| 571 |
if (resp.status === 503) return { error: 'space_waking', detail: 'Space is waking up — wait ~30 s and try again.' };
|
| 572 |
+
return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
}
|
| 574 |
+
if (!resp.ok) return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` };
|
| 575 |
return result;
|
| 576 |
} finally {
|
| 577 |
stopKeepAlive();
|
| 578 |
}
|
| 579 |
}
|
| 580 |
|
| 581 |
+
// Write a progress stage label into the current running storage entry.
|
| 582 |
+
// The popup's storage.onChanged listener picks this up and updates the status text.
|
| 583 |
+
let _currentProgressKey = null;
|
| 584 |
+
async function _writeProgressStage(stage) {
|
| 585 |
+
if (!_currentProgressKey) return;
|
| 586 |
+
try {
|
| 587 |
+
const d = await new Promise(res => chrome.storage.local.get(RESULTS_KEY, res));
|
| 588 |
+
const map = d[RESULTS_KEY] || {};
|
| 589 |
+
const entry = map[_currentProgressKey];
|
| 590 |
+
if (entry && entry.status === 'running') {
|
| 591 |
+
entry.stage = stage;
|
| 592 |
+
await new Promise(res => chrome.storage.local.set({ [RESULTS_KEY]: map }, res));
|
| 593 |
+
}
|
| 594 |
+
} catch (_) { /* non-fatal */ }
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
// ─── DOWNLOAD ────────────────────────────────────────────────────────────────
|
| 598 |
|
| 599 |
async function handleDownload({ format, b64, filename }) {
|
|
@@ -240,7 +240,8 @@ async function restoreResultForTab() {
|
|
| 240 |
if (age < RUNNING_TIMEOUT_MS) {
|
| 241 |
restoredMeta = { job_title: saved.job_title, company: saved.company };
|
| 242 |
runBtn.disabled = true;
|
| 243 |
-
|
|
|
|
| 244 |
return;
|
| 245 |
}
|
| 246 |
return; // stale running marker → treat as not-found
|
|
@@ -382,7 +383,8 @@ chrome.storage.onChanged.addListener((changes, area) => {
|
|
| 382 |
if (age < RUNNING_TIMEOUT_MS) {
|
| 383 |
restoredMeta = { job_title: entry.job_title, company: entry.company };
|
| 384 |
runBtn.disabled = true;
|
| 385 |
-
|
|
|
|
| 386 |
}
|
| 387 |
} else if (entry.status === 'done' && entry.result) {
|
| 388 |
generatedResult = entry.result;
|
|
|
|
| 240 |
if (age < RUNNING_TIMEOUT_MS) {
|
| 241 |
restoredMeta = { job_title: saved.job_title, company: saved.company };
|
| 242 |
runBtn.disabled = true;
|
| 243 |
+
const stageLabel = saved.stage || 'Generating resume…';
|
| 244 |
+
statusEl.innerHTML = `<span class="spinner"></span> ${stageLabel}`;
|
| 245 |
return;
|
| 246 |
}
|
| 247 |
return; // stale running marker → treat as not-found
|
|
|
|
| 383 |
if (age < RUNNING_TIMEOUT_MS) {
|
| 384 |
restoredMeta = { job_title: entry.job_title, company: entry.company };
|
| 385 |
runBtn.disabled = true;
|
| 386 |
+
const stageLabel = entry.stage || 'Generating resume…';
|
| 387 |
+
statusEl.innerHTML = `<span class="spinner"></span> ${stageLabel}`;
|
| 388 |
}
|
| 389 |
} else if (entry.status === 'done' && entry.result) {
|
| 390 |
generatedResult = entry.result;
|
|
@@ -790,17 +790,27 @@ def optimize_latex_resume(
|
|
| 790 |
compile_pdf: bool = True,
|
| 791 |
out_dir: str | None = None,
|
| 792 |
job_title: str = "",
|
|
|
|
|
|
|
| 793 |
) -> Dict:
|
| 794 |
"""End-to-end LaTeX flow: gate → inject → (compile) → measure coverage.
|
| 795 |
|
| 796 |
Returns a report dict compatible with the existing coverage_report shape,
|
| 797 |
plus LaTeX-specific fields (`tex`, `pdf_path`, `engine`, `compiled`).
|
| 798 |
"""
|
| 799 |
-
from .external_ats import external_coverage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
|
| 801 |
latex_src = latex_src or ""
|
| 802 |
base_text = latex_to_text(latex_src)
|
| 803 |
|
|
|
|
| 804 |
decision = decide_includable_terms(
|
| 805 |
jd_text, base_text,
|
| 806 |
maximum_ats_mode=maximum_ats_mode,
|
|
@@ -809,9 +819,11 @@ def optimize_latex_resume(
|
|
| 809 |
blocked_terms=blocked_terms,
|
| 810 |
)
|
| 811 |
expected = decision["expected_terms"]
|
| 812 |
-
|
|
|
|
| 813 |
gated = decision["gated"]
|
| 814 |
|
|
|
|
| 815 |
final_src, injected = inject_keywords(latex_src, includable)
|
| 816 |
final_text = latex_to_text(final_src)
|
| 817 |
|
|
@@ -858,6 +870,7 @@ def optimize_latex_resume(
|
|
| 858 |
})
|
| 859 |
|
| 860 |
if compile_pdf:
|
|
|
|
| 861 |
out_dir = out_dir or tempfile.mkdtemp(prefix="latex_resume_")
|
| 862 |
_company_slug = _safe_jobname(job_title)
|
| 863 |
jobname = f"Saiteja_Tirunagari_{_company_slug}_Resume" if _company_slug else "Saiteja_Tirunagari_Resume"
|
|
|
|
| 790 |
compile_pdf: bool = True,
|
| 791 |
out_dir: str | None = None,
|
| 792 |
job_title: str = "",
|
| 793 |
+
company: str = "",
|
| 794 |
+
progress_callback=None,
|
| 795 |
) -> Dict:
|
| 796 |
"""End-to-end LaTeX flow: gate → inject → (compile) → measure coverage.
|
| 797 |
|
| 798 |
Returns a report dict compatible with the existing coverage_report shape,
|
| 799 |
plus LaTeX-specific fields (`tex`, `pdf_path`, `engine`, `compiled`).
|
| 800 |
"""
|
| 801 |
+
from .external_ats import external_coverage, filter_scraped_noise
|
| 802 |
+
|
| 803 |
+
def _prog(stage, pct):
|
| 804 |
+
if progress_callback:
|
| 805 |
+
try:
|
| 806 |
+
progress_callback(stage, pct)
|
| 807 |
+
except Exception:
|
| 808 |
+
pass
|
| 809 |
|
| 810 |
latex_src = latex_src or ""
|
| 811 |
base_text = latex_to_text(latex_src)
|
| 812 |
|
| 813 |
+
_prog("Analyzing job description…", 10)
|
| 814 |
decision = decide_includable_terms(
|
| 815 |
jd_text, base_text,
|
| 816 |
maximum_ats_mode=maximum_ats_mode,
|
|
|
|
| 819 |
blocked_terms=blocked_terms,
|
| 820 |
)
|
| 821 |
expected = decision["expected_terms"]
|
| 822 |
+
# V1 noise fix: strip LinkedIn UI / metadata noise from the keyword pool
|
| 823 |
+
includable = filter_scraped_noise(decision["includable"], jd_text, company)
|
| 824 |
gated = decision["gated"]
|
| 825 |
|
| 826 |
+
_prog("Placing keywords in resume…", 45)
|
| 827 |
final_src, injected = inject_keywords(latex_src, includable)
|
| 828 |
final_text = latex_to_text(final_src)
|
| 829 |
|
|
|
|
| 870 |
})
|
| 871 |
|
| 872 |
if compile_pdf:
|
| 873 |
+
_prog("Compiling PDF…", 68)
|
| 874 |
out_dir = out_dir or tempfile.mkdtemp(prefix="latex_resume_")
|
| 875 |
_company_slug = _safe_jobname(job_title)
|
| 876 |
jobname = f"Saiteja_Tirunagari_{_company_slug}_Resume" if _company_slug else "Saiteja_Tirunagari_Resume"
|
|
@@ -815,6 +815,7 @@ def generate_v2(
|
|
| 815 |
judge: dict | None = None,
|
| 816 |
out_dir: str | None = None,
|
| 817 |
compile_pdf: bool = True,
|
|
|
|
| 818 |
) -> dict:
|
| 819 |
"""V2 multi-agent pipeline: extract keywords → ALL agents rank them by potential
|
| 820 |
→ allocate the top-ranked → every agent writes a natural candidate → judge picks
|
|
@@ -822,10 +823,18 @@ def generate_v2(
|
|
| 822 |
|
| 823 |
Falls back to single-model, then V1-style placement, if the pool is unavailable."""
|
| 824 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
# 0. Sanitize the JD: strip scraped job-board page chrome (promoted-by,
|
| 826 |
# profile-match widgets, applicant counts, premium upsells, section labels)
|
| 827 |
# so keyword extraction + the coverage denominator see the REAL job
|
| 828 |
# description, not LinkedIn UI text. V2-only.
|
|
|
|
| 829 |
jd_text = _sanitize_jd_v2(jd_text)
|
| 830 |
|
| 831 |
base_text = latex_to_text(latex_src or "")
|
|
@@ -835,6 +844,7 @@ def generate_v2(
|
|
| 835 |
llm = LLMClient() if (fan_cfgs or (judge_cfg and judge_cfg.get("api_key"))) else None
|
| 836 |
|
| 837 |
# 1. Extract + V2-noise-filter the keyword pool.
|
|
|
|
| 838 |
decision = decide_includable_terms(jd_text, base_text, maximum_ats_mode=True)
|
| 839 |
includable = filter_scraped_noise(decision["includable"], jd_text, company)
|
| 840 |
|
|
@@ -859,6 +869,7 @@ def generate_v2(
|
|
| 859 |
# LLM round (~5-8 s wall-clock).
|
| 860 |
rank_note = ""
|
| 861 |
_curate_fut = _rank_fut = None
|
|
|
|
| 862 |
with ThreadPoolExecutor(max_workers=2) as _prep_pool:
|
| 863 |
if llm and judge_cfg and judge_cfg.get("api_key"):
|
| 864 |
_curate_fut = _prep_pool.submit(
|
|
@@ -906,6 +917,7 @@ def generate_v2(
|
|
| 906 |
# DEFAULT = multi-agent fan-out: every fast model writes a full natural
|
| 907 |
# candidate, the judge (Kimi) picks the best, then Kimi runs one refine pass.
|
| 908 |
# Falls back to single-model, then V1 placement.
|
|
|
|
| 909 |
sentences: dict = {}
|
| 910 |
v2_models_used: list[str] = []
|
| 911 |
v2_winner = "v1_fallback"
|
|
@@ -1051,6 +1063,7 @@ def generate_v2(
|
|
| 1051 |
log.warning("Coverage backstop failed (non-fatal): %s", exc)
|
| 1052 |
|
| 1053 |
# 5. Compile
|
|
|
|
| 1054 |
comp: dict = {"compiled": False, "engine": None, "pdf_path": None}
|
| 1055 |
if compile_pdf and out_dir:
|
| 1056 |
try:
|
|
|
|
| 815 |
judge: dict | None = None,
|
| 816 |
out_dir: str | None = None,
|
| 817 |
compile_pdf: bool = True,
|
| 818 |
+
progress_callback=None,
|
| 819 |
) -> dict:
|
| 820 |
"""V2 multi-agent pipeline: extract keywords → ALL agents rank them by potential
|
| 821 |
→ allocate the top-ranked → every agent writes a natural candidate → judge picks
|
|
|
|
| 823 |
|
| 824 |
Falls back to single-model, then V1-style placement, if the pool is unavailable."""
|
| 825 |
|
| 826 |
+
def _prog(stage, pct):
|
| 827 |
+
if progress_callback:
|
| 828 |
+
try:
|
| 829 |
+
progress_callback(stage, pct)
|
| 830 |
+
except Exception:
|
| 831 |
+
pass
|
| 832 |
+
|
| 833 |
# 0. Sanitize the JD: strip scraped job-board page chrome (promoted-by,
|
| 834 |
# profile-match widgets, applicant counts, premium upsells, section labels)
|
| 835 |
# so keyword extraction + the coverage denominator see the REAL job
|
| 836 |
# description, not LinkedIn UI text. V2-only.
|
| 837 |
+
_prog("Analyzing job description…", 5)
|
| 838 |
jd_text = _sanitize_jd_v2(jd_text)
|
| 839 |
|
| 840 |
base_text = latex_to_text(latex_src or "")
|
|
|
|
| 844 |
llm = LLMClient() if (fan_cfgs or (judge_cfg and judge_cfg.get("api_key"))) else None
|
| 845 |
|
| 846 |
# 1. Extract + V2-noise-filter the keyword pool.
|
| 847 |
+
_prog("Extracting keywords…", 10)
|
| 848 |
decision = decide_includable_terms(jd_text, base_text, maximum_ats_mode=True)
|
| 849 |
includable = filter_scraped_noise(decision["includable"], jd_text, company)
|
| 850 |
|
|
|
|
| 869 |
# LLM round (~5-8 s wall-clock).
|
| 870 |
rank_note = ""
|
| 871 |
_curate_fut = _rank_fut = None
|
| 872 |
+
_prog("Curating and ranking keywords…", 20)
|
| 873 |
with ThreadPoolExecutor(max_workers=2) as _prep_pool:
|
| 874 |
if llm and judge_cfg and judge_cfg.get("api_key"):
|
| 875 |
_curate_fut = _prep_pool.submit(
|
|
|
|
| 917 |
# DEFAULT = multi-agent fan-out: every fast model writes a full natural
|
| 918 |
# candidate, the judge (Kimi) picks the best, then Kimi runs one refine pass.
|
| 919 |
# Falls back to single-model, then V1 placement.
|
| 920 |
+
_prog("Generating tailored resume bullets…", 38)
|
| 921 |
sentences: dict = {}
|
| 922 |
v2_models_used: list[str] = []
|
| 923 |
v2_winner = "v1_fallback"
|
|
|
|
| 1063 |
log.warning("Coverage backstop failed (non-fatal): %s", exc)
|
| 1064 |
|
| 1065 |
# 5. Compile
|
| 1066 |
+
_prog("Compiling PDF…", 82)
|
| 1067 |
comp: dict = {"compiled": False, "engine": None, "pdf_path": None}
|
| 1068 |
if compile_pdf and out_dir:
|
| 1069 |
try:
|