saitejatirunagari Claude Sonnet 4.6 commited on
Commit
196454b
·
1 Parent(s): 38e79b5

feat: V1 noise fix, real progress animation via SSE

Browse files

V1 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 CHANGED
@@ -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
- from fastapi.responses import JSONResponse
 
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,
extension/background.js CHANGED
@@ -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 — so the
448
- // popup never shows a false/forever spinner. Done regardless of popup state.
449
  if (result && result.error) {
450
  await writeEntry(urlKey, { status: 'error', result });
451
  } else {
@@ -480,9 +483,8 @@ function stopKeepAlive() {
480
  }
481
  }
482
 
483
- // Network/parse layer for GENERATE. Returns a result object (success or {error}).
484
- async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version }) {
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
- bytes[i] = binaryStr.charCodeAt(i);
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 _fetchWithRetry(`${data.api_url.replace(/\/$/, '')}/api/generate`, {
511
- method: 'POST',
512
- headers: { 'X-Api-Token': data.api_token },
513
- body: formData,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` };
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 }) {
extension/popup/popup.js CHANGED
@@ -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
- statusEl.innerHTML = '<span class="spinner"></span> Generating resume…';
 
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
- statusEl.innerHTML = '<span class="spinner"></span> Generating resume…';
 
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;
src/latex_resume.py CHANGED
@@ -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
- includable = decision["includable"]
 
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"
src/resume_v2_natural.py CHANGED
@@ -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: