Spaces:
Sleeping
perf+feat: parallel resume generation, PDF output, full JD fetching, API timeouts
Browse filesWhy runs hung ("taking lot of time, not going forward"):
- OpenAI clients had NO request timeout — one stalled NVIDIA API call
blocked the pipeline for 10+ min per retry (observed: 85-min resume step).
Now: 90s timeout in llm_client, 120s in model_pool, fail fast + own retries.
- LLM resumes were generated sequentially (50-150s each); now parallel via
ThreadPoolExecutor, 6 workers round-robin across phase2 model API keys.
- Indeed launched one Chromium PER job description; now one browser session
for all (get_details_bulk).
Why ATS scores were broken:
- LinkedIn switched to slug URLs; job_id regex never matched → all detail
fetches 404'd → no JDs → meaningless ATS scores. Fixed extraction.
- Glassdoor had no description fetching at all; added bulk fetch with
Cloudflare wait + JSON-LD parsing.
- _inject_missing_keywords injected generic PM keywords; now injects the
ACTUAL missing JD keywords → tailored resumes verified at 90-93% ATS.
- Optimization loop now keeps the BEST attempt (was keeping last), validates
LLM JSON (retries empty/truncated), and guarantees After >= Before by
falling back to keyword-injected template.
New:
- src/pdf_writer.py: every resume saved as PDF too (Word COM batch on
Windows, reportlab fallback on Linux/HF). UI offers PDF + DOCX downloads.
- UI searches ALL selected roles x locations; cross-platform dedup by
(title, company); live per-resume progress.
Verified E2E: 6 jobs with full JDs -> 3 LLM resumes all >=90% ATS
(51->91, 45->91, 56->93), 6 PDFs, resume step 151s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .gitignore +1 -0
- HISTORY.md +48 -0
- README.md +7 -7
- requirements.txt +2 -0
- src/llm_client.py +36 -9
- src/model_pool.py +3 -1
- src/pdf_writer.py +168 -0
- src/resume_customizer.py +181 -83
- src/scrapers/glassdoor.py +63 -0
- src/scrapers/indeed.py +40 -0
- src/scrapers/linkedin.py +38 -2
- ui.py +70 -29
|
@@ -36,3 +36,4 @@ Thumbs.db
|
|
| 36 |
|
| 37 |
# Streamlit
|
| 38 |
.streamlit/secrets.toml
|
|
|
|
|
|
| 36 |
|
| 37 |
# Streamlit
|
| 38 |
.streamlit/secrets.toml
|
| 39 |
+
data/logs/
|
|
@@ -4,6 +4,54 @@ A running log of everything built, fixed, and changed. Most recent first.
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## Session 7 — 2026-06-12
|
| 8 |
|
| 9 |
### File-based logging system + Logs tab in UI
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## Session 8 — 2026-06-12
|
| 8 |
+
|
| 9 |
+
### Major performance + quality overhaul: parallel resumes, PDF output, full JD fetching
|
| 10 |
+
|
| 11 |
+
**Root causes of "taking lot of time, not going forward":**
|
| 12 |
+
1. LLM resumes generated ONE at a time (50–150s each × 30 = up to an hour, UI frozen)
|
| 13 |
+
2. Indeed launched a full Chromium browser PER job description (~10s overhead each)
|
| 14 |
+
3. Glassdoor NEVER fetched descriptions (no detail method existed)
|
| 15 |
+
4. LinkedIn `job_id` regex broken — LinkedIn switched to slug URLs
|
| 16 |
+
(`/jobs/view/title-at-company-4423634421`), so ALL detail fetches 404'd → no JDs
|
| 17 |
+
5. UI capped search to 3 roles × 2 locations
|
| 18 |
+
|
| 19 |
+
**Fixes:**
|
| 20 |
+
- `src/resume_customizer.py` — LLM resumes now generated IN PARALLEL via
|
| 21 |
+
ThreadPoolExecutor (6 workers, round-robin across phase2 model API keys).
|
| 22 |
+
Per-resume `progress_cb` streams live status to the UI.
|
| 23 |
+
- `src/scrapers/linkedin.py` — fixed job_id extraction (slug URLs); new
|
| 24 |
+
`get_details_bulk()` fetches ALL descriptions with 4 parallel HTTP workers
|
| 25 |
+
- `src/scrapers/indeed.py` — new `get_details_bulk()`: ONE browser session for
|
| 26 |
+
all job descriptions instead of one browser per job
|
| 27 |
+
- `src/scrapers/glassdoor.py` — new `get_details_bulk()` with Cloudflare-challenge
|
| 28 |
+
wait + JSON-LD JobPosting parsing (Glassdoor still intermittent — bot-hostile)
|
| 29 |
+
- `ui.py` — searches ALL selected roles × locations (caps removed); cross-platform
|
| 30 |
+
dedup by (title, company) in addition to URL; live per-resume progress
|
| 31 |
+
|
| 32 |
+
**ATS quality fixes (tailored resumes were sometimes scoring LOWER than original):**
|
| 33 |
+
- `src/llm_client.py` — validates LLM customization (summary >50 chars, ≥5 skills),
|
| 34 |
+
retries once, unwraps JSON arrays, max_tokens 3000→4000
|
| 35 |
+
- `resume_customizer.py` — optimization loop now: scores with same extra_kw as
|
| 36 |
+
final report · skips empty customizations · retries fall back to Kimi · rewrites
|
| 37 |
+
BEST attempt to disk (was keeping last) · GUARANTEE: if LLM result scores below
|
| 38 |
+
the original resume, ships keyword-injected template instead (After ≥ Before always)
|
| 39 |
+
- `_inject_missing_keywords()` rewritten — now injects the ACTUAL missing JD
|
| 40 |
+
keywords (was injecting generic PM keywords that didn't move the JD-match score)
|
| 41 |
+
|
| 42 |
+
**PDF output (new):**
|
| 43 |
+
- `src/pdf_writer.py` — DOCX→PDF: one Word COM session per batch on Windows
|
| 44 |
+
(perfect fidelity), reportlab re-render fallback on Linux/HF Spaces
|
| 45 |
+
- Every resume now saved as both `.docx` and `.pdf` in `data/output/resumes/YYYY-MM-DD/`
|
| 46 |
+
- UI: PDF + DOCX download buttons per job; zip download includes PDFs
|
| 47 |
+
- `requirements.txt`: + reportlab, docx2pdf (win32 only)
|
| 48 |
+
|
| 49 |
+
**Files changed:** `src/pdf_writer.py` (new), `src/resume_customizer.py`,
|
| 50 |
+
`src/llm_client.py`, `src/scrapers/linkedin.py`, `src/scrapers/indeed.py`,
|
| 51 |
+
`src/scrapers/glassdoor.py`, `ui.py`, `requirements.txt`, `README.md`, `HISTORY.md`
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
## Session 7 — 2026-06-12
|
| 56 |
|
| 57 |
### File-based logging system + Logs tab in UI
|
|
@@ -19,14 +19,14 @@ Automated Product Manager job search, AI-powered assessment, ATS-optimized resum
|
|
| 19 |
|
| 20 |
| Step | What Happens |
|
| 21 |
|------|--------------|
|
| 22 |
-
| 1 | Parses your PDF resume (
|
| 23 |
-
| 2 | Scrapes PM-only jobs from LinkedIn, Indeed, Glassdoor |
|
| 24 |
-
| 3 | Filters non-PM roles at scrape time (
|
| 25 |
-
| 4 |
|
| 26 |
| 5 | Assesses ALL jobs using 7–10 parallel AI models (10-model pool via NVIDIA API) |
|
| 27 |
-
| 6 | Generates ATS-optimized
|
| 28 |
-
| 7 | Writes everything to your Google Sheet with direct job links
|
| 29 |
-
| 8 | Saves local Excel report |
|
| 30 |
|
| 31 |
---
|
| 32 |
|
|
|
|
| 19 |
|
| 20 |
| Step | What Happens |
|
| 21 |
|------|--------------|
|
| 22 |
+
| 1 | Parses your PDF resume (Kimi-K2.6, ~5s) |
|
| 23 |
+
| 2 | Scrapes PM-only jobs from LinkedIn, Indeed, Glassdoor — ALL selected roles × locations, last 7 days |
|
| 24 |
+
| 3 | Filters non-PM roles at scrape time; dedup by URL + (title, company) + 30-day SQLite history |
|
| 25 |
+
| 4 | Fetches FULL job descriptions in bulk (parallel HTTP / single browser session) |
|
| 26 |
| 5 | Assesses ALL jobs using 7–10 parallel AI models (10-model pool via NVIDIA API) |
|
| 27 |
+
| 6 | Generates ATS-optimized resumes IN PARALLEL (6 workers) — DOCX **and PDF**, After ≥ Before guaranteed, target 95% |
|
| 28 |
+
| 7 | Writes everything to your Google Sheet with direct job links |
|
| 29 |
+
| 8 | Saves local Excel report + resumes in `data/output/resumes/YYYY-MM-DD/` |
|
| 30 |
|
| 31 |
---
|
| 32 |
|
|
@@ -11,6 +11,8 @@ colorama>=0.4.6
|
|
| 11 |
# Resume
|
| 12 |
pdfplumber>=0.10.0
|
| 13 |
python-docx>=1.1.0
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# Excel
|
| 16 |
pandas>=2.1.0
|
|
|
|
| 11 |
# Resume
|
| 12 |
pdfplumber>=0.10.0
|
| 13 |
python-docx>=1.1.0
|
| 14 |
+
reportlab>=4.0.0
|
| 15 |
+
docx2pdf>=0.1.8; sys_platform == 'win32'
|
| 16 |
|
| 17 |
# Excel
|
| 18 |
pandas>=2.1.0
|
|
@@ -6,10 +6,15 @@ from config import NVIDIA_API_KEY, GLM_BASE_URL, GLM_MODEL
|
|
| 6 |
|
| 7 |
|
| 8 |
class LLMClient:
|
|
|
|
|
|
|
|
|
|
| 9 |
def __init__(self):
|
| 10 |
self.client = OpenAI(
|
| 11 |
base_url=GLM_BASE_URL,
|
| 12 |
api_key=NVIDIA_API_KEY,
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
self.model = GLM_MODEL
|
| 15 |
|
|
@@ -127,7 +132,8 @@ Return a JSON array with one object per job (in order):
|
|
| 127 |
def _call_with_cfg(self, cfg: dict, system: str, user: str, max_tokens: int = 2000) -> str:
|
| 128 |
"""Call any NVIDIA model using the provided model config dict."""
|
| 129 |
from openai import OpenAI
|
| 130 |
-
client = OpenAI(base_url=cfg.get("base_url"), api_key=cfg["api_key"]
|
|
|
|
| 131 |
extra_body = cfg.get("extra_body") or None
|
| 132 |
for attempt in range(3):
|
| 133 |
try:
|
|
@@ -165,11 +171,27 @@ Return a JSON array with one object per job (in order):
|
|
| 165 |
user = self._resume_customize_prompt(
|
| 166 |
resume_text, job_description, job_title, company, ats_keywords, matching_skills
|
| 167 |
)
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
# ──────────────────────────────────────────────────────────
|
| 175 |
# RESUME CUSTOMIZATION
|
|
@@ -185,10 +207,15 @@ Return a JSON array with one object per job (in order):
|
|
| 185 |
resume_text, job_description, job_title, company, ats_keywords, matching_skills
|
| 186 |
)
|
| 187 |
try:
|
| 188 |
-
response = self._call(system, user, max_tokens=
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
except Exception:
|
| 191 |
-
|
|
|
|
| 192 |
|
| 193 |
def _resume_customize_prompt(self, resume_text, job_description, job_title, company,
|
| 194 |
ats_keywords, matching_skills) -> str:
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class LLMClient:
|
| 9 |
+
# Hard cap per API request — a hung call must fail fast, not stall the pipeline
|
| 10 |
+
REQUEST_TIMEOUT = 90.0
|
| 11 |
+
|
| 12 |
def __init__(self):
|
| 13 |
self.client = OpenAI(
|
| 14 |
base_url=GLM_BASE_URL,
|
| 15 |
api_key=NVIDIA_API_KEY,
|
| 16 |
+
timeout=self.REQUEST_TIMEOUT,
|
| 17 |
+
max_retries=0, # we do our own retries with backoff
|
| 18 |
)
|
| 19 |
self.model = GLM_MODEL
|
| 20 |
|
|
|
|
| 132 |
def _call_with_cfg(self, cfg: dict, system: str, user: str, max_tokens: int = 2000) -> str:
|
| 133 |
"""Call any NVIDIA model using the provided model config dict."""
|
| 134 |
from openai import OpenAI
|
| 135 |
+
client = OpenAI(base_url=cfg.get("base_url"), api_key=cfg["api_key"],
|
| 136 |
+
timeout=self.REQUEST_TIMEOUT, max_retries=0)
|
| 137 |
extra_body = cfg.get("extra_body") or None
|
| 138 |
for attempt in range(3):
|
| 139 |
try:
|
|
|
|
| 171 |
user = self._resume_customize_prompt(
|
| 172 |
resume_text, job_description, job_title, company, ats_keywords, matching_skills
|
| 173 |
)
|
| 174 |
+
for attempt in range(2):
|
| 175 |
+
try:
|
| 176 |
+
response = self._call_with_cfg(cfg, system, user, max_tokens=4000)
|
| 177 |
+
data = self._extract_json(response)
|
| 178 |
+
# Some models wrap the object in an array
|
| 179 |
+
if isinstance(data, list):
|
| 180 |
+
data = next((d for d in data if isinstance(d, dict)), {})
|
| 181 |
+
if self._customization_valid(data):
|
| 182 |
+
return data
|
| 183 |
+
except Exception:
|
| 184 |
+
pass
|
| 185 |
+
return self._empty_customization()
|
| 186 |
+
|
| 187 |
+
@staticmethod
|
| 188 |
+
def _customization_valid(data) -> bool:
|
| 189 |
+
"""A usable customization must have a real summary and skills list."""
|
| 190 |
+
return (
|
| 191 |
+
isinstance(data, dict)
|
| 192 |
+
and len(data.get("professional_summary", "") or "") > 50
|
| 193 |
+
and len(data.get("core_competencies", []) or []) >= 5
|
| 194 |
+
)
|
| 195 |
|
| 196 |
# ──────────────────────────────────────────────────────────
|
| 197 |
# RESUME CUSTOMIZATION
|
|
|
|
| 207 |
resume_text, job_description, job_title, company, ats_keywords, matching_skills
|
| 208 |
)
|
| 209 |
try:
|
| 210 |
+
response = self._call(system, user, max_tokens=4000)
|
| 211 |
+
data = self._extract_json(response)
|
| 212 |
+
if isinstance(data, list):
|
| 213 |
+
data = next((d for d in data if isinstance(d, dict)), {})
|
| 214 |
+
if self._customization_valid(data):
|
| 215 |
+
return data
|
| 216 |
except Exception:
|
| 217 |
+
pass
|
| 218 |
+
return self._empty_customization()
|
| 219 |
|
| 220 |
def _resume_customize_prompt(self, resume_text, job_description, job_title, company,
|
| 221 |
ats_keywords, matching_skills) -> str:
|
|
@@ -33,7 +33,9 @@ class ModelWorker:
|
|
| 33 |
self.name = name
|
| 34 |
self.model = model
|
| 35 |
self.extra_body = extra_body or {}
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
self.jobs_done = 0
|
| 38 |
self.calls_done = 0
|
| 39 |
self.errors = 0
|
|
|
|
| 33 |
self.name = name
|
| 34 |
self.model = model
|
| 35 |
self.extra_body = extra_body or {}
|
| 36 |
+
# Hard timeout so a hung API call fails fast instead of stalling the pool
|
| 37 |
+
self.client = OpenAI(base_url=base_url, api_key=api_key,
|
| 38 |
+
timeout=120.0, max_retries=0)
|
| 39 |
self.jobs_done = 0
|
| 40 |
self.calls_done = 0
|
| 41 |
self.errors = 0
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DOCX → PDF conversion.
|
| 3 |
+
|
| 4 |
+
Strategy:
|
| 5 |
+
1. Windows + MS Word installed → docx2pdf (perfect fidelity)
|
| 6 |
+
2. Anywhere else (HF Spaces, Linux) → reportlab re-render from docx text
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
log = logging.getLogger("pdf_writer")
|
| 13 |
+
|
| 14 |
+
_WORD_AVAILABLE = None # cached after first check
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _word_available() -> bool:
|
| 18 |
+
global _WORD_AVAILABLE
|
| 19 |
+
if _WORD_AVAILABLE is not None:
|
| 20 |
+
return _WORD_AVAILABLE
|
| 21 |
+
if os.name != "nt":
|
| 22 |
+
_WORD_AVAILABLE = False
|
| 23 |
+
return False
|
| 24 |
+
try:
|
| 25 |
+
import win32com.client # noqa: F401 (docx2pdf dependency)
|
| 26 |
+
import pythoncom
|
| 27 |
+
pythoncom.CoInitialize()
|
| 28 |
+
try:
|
| 29 |
+
import win32com.client as wc
|
| 30 |
+
w = wc.Dispatch("Word.Application")
|
| 31 |
+
w.Quit()
|
| 32 |
+
_WORD_AVAILABLE = True
|
| 33 |
+
finally:
|
| 34 |
+
pythoncom.CoUninitialize()
|
| 35 |
+
except Exception:
|
| 36 |
+
_WORD_AVAILABLE = False
|
| 37 |
+
return _WORD_AVAILABLE
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def convert_folder(folder: str) -> dict:
|
| 41 |
+
"""
|
| 42 |
+
Convert every .docx in folder to .pdf.
|
| 43 |
+
Returns {abs_docx_path: pdf_path_or_empty}.
|
| 44 |
+
Uses ONE Word session for the whole folder when Word is available
|
| 45 |
+
(much faster + stable than per-file), else reportlab per file.
|
| 46 |
+
"""
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
docx_files = sorted(Path(folder).glob("*.docx"))
|
| 49 |
+
result = {}
|
| 50 |
+
if not docx_files:
|
| 51 |
+
return result
|
| 52 |
+
|
| 53 |
+
# Only convert files that don't already have a PDF
|
| 54 |
+
to_convert = [f for f in docx_files if not f.with_suffix(".pdf").exists()]
|
| 55 |
+
|
| 56 |
+
if to_convert and _word_available():
|
| 57 |
+
try:
|
| 58 |
+
import pythoncom
|
| 59 |
+
pythoncom.CoInitialize()
|
| 60 |
+
try:
|
| 61 |
+
from docx2pdf import convert
|
| 62 |
+
if len(to_convert) == len(docx_files):
|
| 63 |
+
convert(folder) # whole folder in one Word session
|
| 64 |
+
else:
|
| 65 |
+
for f in to_convert:
|
| 66 |
+
convert(str(f), str(f.with_suffix(".pdf")))
|
| 67 |
+
finally:
|
| 68 |
+
pythoncom.CoUninitialize()
|
| 69 |
+
except Exception as e:
|
| 70 |
+
log.warning(f"Batch docx2pdf failed: {e} — falling back to reportlab per file")
|
| 71 |
+
|
| 72 |
+
for f in docx_files:
|
| 73 |
+
pdf = str(f.with_suffix(".pdf"))
|
| 74 |
+
if not os.path.exists(pdf):
|
| 75 |
+
pdf = _reportlab_render(str(f), pdf)
|
| 76 |
+
result[os.path.abspath(str(f))] = pdf if pdf and os.path.exists(pdf) else ""
|
| 77 |
+
return result
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def docx_to_pdf(docx_path: str) -> str:
|
| 81 |
+
"""
|
| 82 |
+
Convert a DOCX resume to PDF next to it. Returns the PDF path ('' on failure).
|
| 83 |
+
Thread-safe: each call initializes its own COM context on Windows.
|
| 84 |
+
"""
|
| 85 |
+
if not docx_path or not os.path.exists(docx_path):
|
| 86 |
+
return ""
|
| 87 |
+
pdf_path = os.path.splitext(docx_path)[0] + ".pdf"
|
| 88 |
+
|
| 89 |
+
if _word_available():
|
| 90 |
+
try:
|
| 91 |
+
import pythoncom
|
| 92 |
+
pythoncom.CoInitialize()
|
| 93 |
+
try:
|
| 94 |
+
from docx2pdf import convert
|
| 95 |
+
convert(docx_path, pdf_path)
|
| 96 |
+
finally:
|
| 97 |
+
pythoncom.CoUninitialize()
|
| 98 |
+
if os.path.exists(pdf_path):
|
| 99 |
+
return pdf_path
|
| 100 |
+
except Exception as e:
|
| 101 |
+
log.warning(f"docx2pdf failed for {os.path.basename(docx_path)}: {e} — falling back to reportlab")
|
| 102 |
+
|
| 103 |
+
return _reportlab_render(docx_path, pdf_path)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _reportlab_render(docx_path: str, pdf_path: str) -> str:
|
| 107 |
+
"""Re-render the DOCX content as a clean styled PDF using reportlab."""
|
| 108 |
+
try:
|
| 109 |
+
from docx import Document
|
| 110 |
+
from reportlab.lib.pagesizes import A4
|
| 111 |
+
from reportlab.lib.units import inch
|
| 112 |
+
from reportlab.lib.colors import HexColor
|
| 113 |
+
from reportlab.lib.styles import ParagraphStyle
|
| 114 |
+
from reportlab.lib.enums import TA_CENTER
|
| 115 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
| 116 |
+
|
| 117 |
+
doc = Document(docx_path)
|
| 118 |
+
paragraphs = [(p.text, p.style.name if p.style else "") for p in doc.paragraphs]
|
| 119 |
+
|
| 120 |
+
styles = {
|
| 121 |
+
"name": ParagraphStyle("name", fontName="Helvetica-Bold", fontSize=18,
|
| 122 |
+
textColor=HexColor("#1A1A2E"), alignment=TA_CENTER, spaceAfter=4),
|
| 123 |
+
"contact": ParagraphStyle("contact", fontName="Helvetica", fontSize=9,
|
| 124 |
+
textColor=HexColor("#444444"), alignment=TA_CENTER, spaceAfter=6),
|
| 125 |
+
"header": ParagraphStyle("header", fontName="Helvetica-Bold", fontSize=11,
|
| 126 |
+
textColor=HexColor("#16489E"), spaceBefore=10, spaceAfter=4),
|
| 127 |
+
"bullet": ParagraphStyle("bullet", fontName="Helvetica", fontSize=10,
|
| 128 |
+
leftIndent=14, bulletIndent=4, spaceAfter=2, leading=13),
|
| 129 |
+
"body": ParagraphStyle("body", fontName="Helvetica", fontSize=10,
|
| 130 |
+
spaceAfter=3, leading=13),
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
pdf = SimpleDocTemplate(pdf_path, pagesize=A4,
|
| 134 |
+
topMargin=0.6 * inch, bottomMargin=0.6 * inch,
|
| 135 |
+
leftMargin=0.7 * inch, rightMargin=0.7 * inch)
|
| 136 |
+
flow = []
|
| 137 |
+
first_text_seen = False
|
| 138 |
+
|
| 139 |
+
def esc(t):
|
| 140 |
+
return t.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 141 |
+
|
| 142 |
+
for text, style_name in paragraphs:
|
| 143 |
+
text = text.strip()
|
| 144 |
+
if not text:
|
| 145 |
+
continue
|
| 146 |
+
if set(text) <= {"─", "-", "—", "_"}:
|
| 147 |
+
continue # horizontal rules
|
| 148 |
+
if not first_text_seen:
|
| 149 |
+
flow.append(Paragraph(esc(text), styles["name"]))
|
| 150 |
+
first_text_seen = True
|
| 151 |
+
elif "|" in text and ("@" in text or re.search(r"\+?\d{6,}", text)):
|
| 152 |
+
flow.append(Paragraph(esc(text), styles["contact"]))
|
| 153 |
+
elif re.match(r"^[A-Z][A-Z\s&/]+$", text) and len(text) > 3:
|
| 154 |
+
flow.append(Paragraph(esc(text), styles["header"]))
|
| 155 |
+
elif style_name == "List Bullet" or text.startswith(("•", "-", "–", "▪")):
|
| 156 |
+
clean = text.lstrip("•-–▪* ").strip()
|
| 157 |
+
flow.append(Paragraph(f"• {esc(clean)}", styles["bullet"]))
|
| 158 |
+
else:
|
| 159 |
+
flow.append(Paragraph(esc(text), styles["body"]))
|
| 160 |
+
|
| 161 |
+
if not flow:
|
| 162 |
+
return ""
|
| 163 |
+
flow.append(Spacer(1, 6))
|
| 164 |
+
pdf.build(flow)
|
| 165 |
+
return pdf_path if os.path.exists(pdf_path) else ""
|
| 166 |
+
except Exception as e:
|
| 167 |
+
log.error(f"reportlab PDF render failed for {os.path.basename(docx_path)}: {e}")
|
| 168 |
+
return ""
|
|
@@ -40,72 +40,102 @@ class ResumeCustomizer:
|
|
| 40 |
min_score_for_llm: int = 6,
|
| 41 |
max_llm_resumes: int = 20,
|
| 42 |
generate_all: bool = True,
|
|
|
|
|
|
|
| 43 |
) -> list[dict]:
|
| 44 |
"""
|
| 45 |
Generate resumes for jobs:
|
| 46 |
-
- LLM-tailored (high quality) for jobs scoring >= min_score_for_llm
|
|
|
|
| 47 |
- Template-based (instant) for all other PM-relevant jobs if generate_all=True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
"""
|
| 49 |
-
from .
|
| 50 |
|
| 51 |
llm_eligible = [j for j in assessed_jobs if j.get("relevance_score", 0) >= min_score_for_llm][:max_llm_resumes]
|
| 52 |
template_eligible = [j for j in assessed_jobs if j.get("relevance_score", 0) < min_score_for_llm and generate_all]
|
| 53 |
|
| 54 |
print(f"\n{Fore.CYAN}Generating resumes:")
|
| 55 |
-
print(f" LLM-tailored: {len(llm_eligible)} jobs (score >= {min_score_for_llm})")
|
| 56 |
print(f" Template-only: {len(template_eligible)} jobs{Style.RESET_ALL}")
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
ttl = job.get("title", "?")[:30]
|
| 63 |
-
try:
|
| 64 |
-
path = self._generate_resume(job, use_llm=True)
|
| 65 |
-
job["resume_path"] = path
|
| 66 |
-
job["resume_generated"] = "LLM Tailored"
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
assessed_kw = [k.strip() for k in job.get("ats_keywords","").split(",") if k.strip()]
|
| 71 |
-
orig_result = score_resume(self.resume_text, jd, extra_kw=assessed_kw)
|
| 72 |
-
job["resume_quality_score"] = orig_result.get("resume_quality", 0)
|
| 73 |
-
|
| 74 |
-
if jd and path and os.path.exists(path):
|
| 75 |
-
from docx import Document as _Doc
|
| 76 |
-
doc_text = "\n".join(p.text for p in _Doc(path).paragraphs)
|
| 77 |
-
b, a, imp = _sba(self.resume_text, doc_text, jd, extra_kw=assessed_kw)
|
| 78 |
-
job["ats_score_before"] = b
|
| 79 |
-
job["ats_score_after"] = a
|
| 80 |
-
job["ats_improvement"] = imp
|
| 81 |
-
tqdm.write(f" {Fore.GREEN}✓ LLM resume: {co} → ATS {b}% → {a}% (+{imp}pp){Style.RESET_ALL}")
|
| 82 |
-
else:
|
| 83 |
-
job["ats_score_before"] = orig_result["ats_score"]
|
| 84 |
-
job["ats_score_after"] = orig_result["ats_score"]
|
| 85 |
-
job["ats_improvement"] = 0
|
| 86 |
-
tqdm.write(f" {Fore.YELLOW}⚠ Resume saved (no JD for ATS comparison): {co}{Style.RESET_ALL}")
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
# Template resumes for the rest
|
| 111 |
with tqdm(total=len(template_eligible), desc="Template resumes", colour="cyan") as pbar:
|
|
@@ -129,9 +159,27 @@ class ResumeCustomizer:
|
|
| 129 |
job["resume_generated"] = f"Error: {e}"
|
| 130 |
pbar.update(1)
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
return assessed_jobs
|
| 133 |
|
| 134 |
-
def _generate_resume(self, job: dict, use_llm: bool = True) -> str:
|
| 135 |
company = re.sub(r'[\\/*?:"<>|]', "", job.get("company", "Company"))
|
| 136 |
title = re.sub(r'[\\/*?:"<>|]', "", job.get("title", "Role"))
|
| 137 |
# ── No score in filename ──
|
|
@@ -141,6 +189,9 @@ class ResumeCustomizer:
|
|
| 141 |
if not use_llm:
|
| 142 |
return self._generate_template_resume(filepath, job)
|
| 143 |
|
|
|
|
|
|
|
|
|
|
| 144 |
# ── LLM customization with 95% ATS guarantee ──
|
| 145 |
from .ats_scorer import score_resume as _score_resume, get_gap_report
|
| 146 |
|
|
@@ -151,6 +202,16 @@ class ResumeCustomizer:
|
|
| 151 |
parser.pdf_path = ""
|
| 152 |
contact = parser.get_contact_info(self.resume_text)
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
# Iterative optimization loop: up to 3 attempts to reach 95%
|
| 155 |
best_customization = None
|
| 156 |
best_score = 0
|
|
@@ -171,10 +232,11 @@ class ResumeCustomizer:
|
|
| 171 |
except Exception:
|
| 172 |
pass
|
| 173 |
|
| 174 |
-
#
|
| 175 |
-
if self.fast_model_cfg
|
|
|
|
| 176 |
customization = self.llm.customize_resume_fast(
|
| 177 |
-
|
| 178 |
resume_text=self.resume_text + extra_instruction,
|
| 179 |
job_description=jd_text,
|
| 180 |
job_title=job.get("title", ""),
|
|
@@ -190,17 +252,21 @@ class ResumeCustomizer:
|
|
| 190 |
assessment=job.get("_raw_assessment", {}),
|
| 191 |
)
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
# Write DOCX
|
| 194 |
self._write_docx(filepath, job, customization, contact)
|
| 195 |
|
| 196 |
-
# Score the
|
| 197 |
try:
|
| 198 |
from docx import Document as _Doc2
|
| 199 |
doc_text = "\n".join(p.text for p in _Doc2(filepath).paragraphs)
|
| 200 |
-
result = _score_resume(doc_text, jd_text)
|
| 201 |
current_score = result["ats_score"]
|
| 202 |
except Exception:
|
| 203 |
-
current_score =
|
| 204 |
|
| 205 |
if current_score > best_score:
|
| 206 |
best_score = current_score
|
|
@@ -209,39 +275,63 @@ class ResumeCustomizer:
|
|
| 209 |
if current_score >= 95:
|
| 210 |
break # Target reached
|
| 211 |
|
| 212 |
-
#
|
| 213 |
-
if
|
| 214 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
return filepath
|
| 217 |
|
| 218 |
-
def _inject_missing_keywords(self, filepath: str, jd_text: str):
|
| 219 |
"""
|
| 220 |
-
Last-resort: inject missing
|
| 221 |
-
|
| 222 |
"""
|
| 223 |
-
from .ats_scorer import
|
| 224 |
from docx import Document as _Doc
|
| 225 |
|
| 226 |
try:
|
| 227 |
-
doc
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
|
| 238 |
-
for
|
| 239 |
-
|
| 240 |
-
if missing_kw or missing_skills:
|
| 241 |
-
additions = missing_kw[:5] + missing_skills[:5]
|
| 242 |
-
run = para.add_run(" | " + " • ".join(additions[:8]))
|
| 243 |
-
run.font.size = Pt(9)
|
| 244 |
-
break
|
| 245 |
|
| 246 |
doc.save(filepath)
|
| 247 |
except Exception:
|
|
@@ -298,6 +388,9 @@ class ResumeCustomizer:
|
|
| 298 |
|
| 299 |
# ── CORE COMPETENCIES ──
|
| 300 |
skills = customization.get("core_competencies", [])
|
|
|
|
|
|
|
|
|
|
| 301 |
if skills:
|
| 302 |
self._add_section_header(doc, "CORE COMPETENCIES")
|
| 303 |
# 3-column table for skills
|
|
@@ -314,6 +407,11 @@ class ResumeCustomizer:
|
|
| 314 |
# ── WORK EXPERIENCE (from original resume) ──
|
| 315 |
self._add_section_header(doc, "PROFESSIONAL EXPERIENCE")
|
| 316 |
exp_bullets = customization.get("experience_bullets", {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
# Parse experience from original resume
|
| 319 |
exp_sections = self._extract_experience_sections(self.resume_text)
|
|
|
|
| 40 |
min_score_for_llm: int = 6,
|
| 41 |
max_llm_resumes: int = 20,
|
| 42 |
generate_all: bool = True,
|
| 43 |
+
model_cfgs: list[dict] = None,
|
| 44 |
+
progress_cb=None,
|
| 45 |
) -> list[dict]:
|
| 46 |
"""
|
| 47 |
Generate resumes for jobs:
|
| 48 |
+
- LLM-tailored (high quality) for jobs scoring >= min_score_for_llm,
|
| 49 |
+
generated IN PARALLEL across the model pool (one model per worker)
|
| 50 |
- Template-based (instant) for all other PM-relevant jobs if generate_all=True
|
| 51 |
+
|
| 52 |
+
model_cfgs: list of model config dicts; workers round-robin across them so
|
| 53 |
+
each parallel resume hits a different API key (no rate limiting).
|
| 54 |
+
progress_cb: optional callable(done, total, message) for live UI updates.
|
| 55 |
"""
|
| 56 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 57 |
|
| 58 |
llm_eligible = [j for j in assessed_jobs if j.get("relevance_score", 0) >= min_score_for_llm][:max_llm_resumes]
|
| 59 |
template_eligible = [j for j in assessed_jobs if j.get("relevance_score", 0) < min_score_for_llm and generate_all]
|
| 60 |
|
| 61 |
print(f"\n{Fore.CYAN}Generating resumes:")
|
| 62 |
+
print(f" LLM-tailored: {len(llm_eligible)} jobs (score >= {min_score_for_llm}) — PARALLEL")
|
| 63 |
print(f" Template-only: {len(template_eligible)} jobs{Style.RESET_ALL}")
|
| 64 |
|
| 65 |
+
cfg_pool = [c for c in (model_cfgs or []) if c and c.get("api_key")]
|
| 66 |
+
if not cfg_pool and self.fast_model_cfg:
|
| 67 |
+
cfg_pool = [self.fast_model_cfg]
|
| 68 |
+
n_workers = min(6, max(1, len(cfg_pool))) if cfg_pool else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
total = len(llm_eligible)
|
| 71 |
+
done_count = [0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
def _process_one(idx_job):
|
| 74 |
+
idx, job = idx_job
|
| 75 |
+
cfg = cfg_pool[idx % len(cfg_pool)] if cfg_pool else None
|
| 76 |
+
co = job.get("company", "?")[:25]
|
| 77 |
+
ttl = job.get("title", "?")[:30]
|
| 78 |
+
try:
|
| 79 |
+
path = self._generate_resume(job, use_llm=True, cfg=cfg)
|
| 80 |
+
job["resume_path"] = path
|
| 81 |
+
job["resume_generated"] = "LLM Tailored"
|
| 82 |
+
|
| 83 |
+
from .ats_scorer import score_resume, score_before_after as _sba
|
| 84 |
+
jd = job.get("description", "")
|
| 85 |
+
assessed_kw = [k.strip() for k in job.get("ats_keywords","").split(",") if k.strip()]
|
| 86 |
+
orig_result = score_resume(self.resume_text, jd, extra_kw=assessed_kw)
|
| 87 |
+
job["resume_quality_score"] = orig_result.get("resume_quality", 0)
|
| 88 |
+
|
| 89 |
+
if jd and path and os.path.exists(path):
|
| 90 |
+
from docx import Document as _Doc
|
| 91 |
+
doc_text = "\n".join(p.text for p in _Doc(path).paragraphs)
|
| 92 |
+
b, a, imp = _sba(self.resume_text, doc_text, jd, extra_kw=assessed_kw)
|
| 93 |
+
job["ats_score_before"] = b
|
| 94 |
+
job["ats_score_after"] = a
|
| 95 |
+
job["ats_improvement"] = imp
|
| 96 |
+
msg = f"✓ {co} → ATS {b}% → {a}% (+{imp}pp)"
|
| 97 |
+
else:
|
| 98 |
+
job["ats_score_before"] = orig_result["ats_score"]
|
| 99 |
+
job["ats_score_after"] = orig_result["ats_score"]
|
| 100 |
+
job["ats_improvement"] = 0
|
| 101 |
+
msg = f"⚠ {co} — saved, but no JD for ATS comparison"
|
| 102 |
+
|
| 103 |
+
return job, msg
|
| 104 |
+
except Exception as e:
|
| 105 |
+
import traceback
|
| 106 |
+
job["resume_path"] = ""
|
| 107 |
+
job["resume_generated"] = f"Error: {e}"
|
| 108 |
+
# Always score original resume so ATS Before shows in sheet
|
| 109 |
+
try:
|
| 110 |
+
from .ats_scorer import score_resume as _sr_fb
|
| 111 |
+
jd = job.get("description", "")
|
| 112 |
+
kw = [k.strip() for k in job.get("ats_keywords","").split(",") if k.strip()]
|
| 113 |
+
r = _sr_fb(self.resume_text, jd, extra_kw=kw)
|
| 114 |
+
job["ats_score_before"] = r["ats_score"]
|
| 115 |
+
job["ats_score_after"] = r["ats_score"]
|
| 116 |
+
job["ats_improvement"] = 0
|
| 117 |
+
job["resume_quality_score"]= r.get("resume_quality", 0)
|
| 118 |
+
except Exception:
|
| 119 |
+
job["ats_score_before"] = 0
|
| 120 |
+
job["ats_score_after"] = 0
|
| 121 |
+
job["ats_improvement"] = 0
|
| 122 |
+
return job, f"✗ {co} — {ttl}: {e} | {traceback.format_exc().splitlines()[-1][:80]}"
|
| 123 |
+
|
| 124 |
+
if llm_eligible:
|
| 125 |
+
with tqdm(total=total, desc=f"LLM resumes ({n_workers} parallel)", colour="blue") as pbar:
|
| 126 |
+
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
| 127 |
+
futures = [pool.submit(_process_one, (i, j)) for i, j in enumerate(llm_eligible)]
|
| 128 |
+
for fut in as_completed(futures):
|
| 129 |
+
_, msg = fut.result()
|
| 130 |
+
done_count[0] += 1
|
| 131 |
+
color = Fore.GREEN if msg.startswith("✓") else (Fore.YELLOW if msg.startswith("⚠") else Fore.RED)
|
| 132 |
+
tqdm.write(f" {color}{msg}{Style.RESET_ALL}")
|
| 133 |
+
if progress_cb:
|
| 134 |
+
try:
|
| 135 |
+
progress_cb(done_count[0], total, msg)
|
| 136 |
+
except Exception:
|
| 137 |
+
pass
|
| 138 |
+
pbar.update(1)
|
| 139 |
|
| 140 |
# Template resumes for the rest
|
| 141 |
with tqdm(total=len(template_eligible), desc="Template resumes", colour="cyan") as pbar:
|
|
|
|
| 159 |
job["resume_generated"] = f"Error: {e}"
|
| 160 |
pbar.update(1)
|
| 161 |
|
| 162 |
+
# ── Batch DOCX → PDF (one Word session for all files — fast and stable) ──
|
| 163 |
+
if progress_cb:
|
| 164 |
+
try:
|
| 165 |
+
progress_cb(total, total, "Converting resumes to PDF…")
|
| 166 |
+
except Exception:
|
| 167 |
+
pass
|
| 168 |
+
try:
|
| 169 |
+
from .pdf_writer import convert_folder
|
| 170 |
+
pdf_map = convert_folder(self.output_dir)
|
| 171 |
+
for job in assessed_jobs:
|
| 172 |
+
p = job.get("resume_path", "")
|
| 173 |
+
if p:
|
| 174 |
+
job["resume_pdf_path"] = pdf_map.get(os.path.abspath(p), "")
|
| 175 |
+
n_pdf = sum(1 for v in pdf_map.values() if v)
|
| 176 |
+
print(f"{Fore.GREEN}✓ PDFs generated: {n_pdf}/{len(pdf_map)}{Style.RESET_ALL}")
|
| 177 |
+
except Exception as e:
|
| 178 |
+
print(f"{Fore.YELLOW}⚠ PDF conversion failed: {e}{Style.RESET_ALL}")
|
| 179 |
+
|
| 180 |
return assessed_jobs
|
| 181 |
|
| 182 |
+
def _generate_resume(self, job: dict, use_llm: bool = True, cfg: dict = None) -> str:
|
| 183 |
company = re.sub(r'[\\/*?:"<>|]', "", job.get("company", "Company"))
|
| 184 |
title = re.sub(r'[\\/*?:"<>|]', "", job.get("title", "Role"))
|
| 185 |
# ── No score in filename ──
|
|
|
|
| 189 |
if not use_llm:
|
| 190 |
return self._generate_template_resume(filepath, job)
|
| 191 |
|
| 192 |
+
# Per-worker model cfg (parallel mode) falls back to the shared fast cfg
|
| 193 |
+
cfg = cfg or self.fast_model_cfg
|
| 194 |
+
|
| 195 |
# ── LLM customization with 95% ATS guarantee ──
|
| 196 |
from .ats_scorer import score_resume as _score_resume, get_gap_report
|
| 197 |
|
|
|
|
| 202 |
parser.pdf_path = ""
|
| 203 |
contact = parser.get_contact_info(self.resume_text)
|
| 204 |
|
| 205 |
+
# Keywords from assessment — used for ALL scoring so loop + final report agree
|
| 206 |
+
assessed_kw = [k.strip() for k in job.get("ats_keywords", "").split(",") if k.strip()]
|
| 207 |
+
|
| 208 |
+
# Baseline: what the ORIGINAL resume scores against this JD.
|
| 209 |
+
# The tailored resume must never end up below this.
|
| 210 |
+
try:
|
| 211 |
+
baseline = _score_resume(self.resume_text, jd_text, extra_kw=assessed_kw)["ats_score"]
|
| 212 |
+
except Exception:
|
| 213 |
+
baseline = 0
|
| 214 |
+
|
| 215 |
# Iterative optimization loop: up to 3 attempts to reach 95%
|
| 216 |
best_customization = None
|
| 217 |
best_score = 0
|
|
|
|
| 232 |
except Exception:
|
| 233 |
pass
|
| 234 |
|
| 235 |
+
# Retry attempts fall back to the primary fast model (most reliable JSON)
|
| 236 |
+
attempt_cfg = cfg if attempt == 0 else (self.fast_model_cfg or cfg)
|
| 237 |
+
if attempt_cfg:
|
| 238 |
customization = self.llm.customize_resume_fast(
|
| 239 |
+
attempt_cfg,
|
| 240 |
resume_text=self.resume_text + extra_instruction,
|
| 241 |
job_description=jd_text,
|
| 242 |
job_title=job.get("title", ""),
|
|
|
|
| 252 |
assessment=job.get("_raw_assessment", {}),
|
| 253 |
)
|
| 254 |
|
| 255 |
+
# Empty/invalid customization → don't write a hollow resume, try again
|
| 256 |
+
if not customization.get("professional_summary") and not customization.get("core_competencies"):
|
| 257 |
+
continue
|
| 258 |
+
|
| 259 |
# Write DOCX
|
| 260 |
self._write_docx(filepath, job, customization, contact)
|
| 261 |
|
| 262 |
+
# Score with the SAME keywords used in the final before/after report
|
| 263 |
try:
|
| 264 |
from docx import Document as _Doc2
|
| 265 |
doc_text = "\n".join(p.text for p in _Doc2(filepath).paragraphs)
|
| 266 |
+
result = _score_resume(doc_text, jd_text, extra_kw=assessed_kw)
|
| 267 |
current_score = result["ats_score"]
|
| 268 |
except Exception:
|
| 269 |
+
current_score = 0
|
| 270 |
|
| 271 |
if current_score > best_score:
|
| 272 |
best_score = current_score
|
|
|
|
| 275 |
if current_score >= 95:
|
| 276 |
break # Target reached
|
| 277 |
|
| 278 |
+
# Make sure the file on disk is the BEST attempt, not just the last one
|
| 279 |
+
if best_customization is not None:
|
| 280 |
+
self._write_docx(filepath, job, best_customization, contact)
|
| 281 |
+
|
| 282 |
+
# If still below 95, inject missing keywords directly
|
| 283 |
+
if best_customization is not None and best_score < 95:
|
| 284 |
+
self._inject_missing_keywords(filepath, jd_text, extra_kw=assessed_kw)
|
| 285 |
+
try:
|
| 286 |
+
from docx import Document as _Doc3
|
| 287 |
+
doc_text = "\n".join(p.text for p in _Doc3(filepath).paragraphs)
|
| 288 |
+
best_score = _score_resume(doc_text, jd_text, extra_kw=assessed_kw)["ats_score"]
|
| 289 |
+
except Exception:
|
| 290 |
+
pass
|
| 291 |
+
|
| 292 |
+
# GUARANTEE: tailored must beat the original. If every LLM attempt failed
|
| 293 |
+
# or scored below the original resume, ship the template (original content)
|
| 294 |
+
# with missing JD keywords injected, so ATS After is never worse than Before.
|
| 295 |
+
if best_customization is None or best_score < baseline:
|
| 296 |
+
path = self._generate_template_resume(filepath, job)
|
| 297 |
+
if jd_text:
|
| 298 |
+
self._inject_missing_keywords(path, jd_text, extra_kw=assessed_kw)
|
| 299 |
+
return path
|
| 300 |
|
| 301 |
return filepath
|
| 302 |
|
| 303 |
+
def _inject_missing_keywords(self, filepath: str, jd_text: str, extra_kw: list = None):
|
| 304 |
"""
|
| 305 |
+
Last-resort: inject the ACTUAL missing JD keywords into the resume
|
| 306 |
+
so the JD-match component (70% of ATS score) reaches the target.
|
| 307 |
"""
|
| 308 |
+
from .ats_scorer import extract_jd_keywords, _kw_in_text
|
| 309 |
from docx import Document as _Doc
|
| 310 |
|
| 311 |
try:
|
| 312 |
+
doc = _Doc(filepath)
|
| 313 |
+
doc_text = "\n".join(p.text for p in doc.paragraphs).lower()
|
| 314 |
+
|
| 315 |
+
# Full JD keyword list (regex-extracted + assessment LLM keywords)
|
| 316 |
+
jd_keywords = extract_jd_keywords(jd_text)
|
| 317 |
+
for kw in (extra_kw or []):
|
| 318 |
+
if kw and kw.lower() not in jd_keywords:
|
| 319 |
+
jd_keywords.append(kw.lower())
|
| 320 |
+
|
| 321 |
+
missing = [kw for kw in jd_keywords if not _kw_in_text(kw, doc_text)]
|
| 322 |
+
if not missing:
|
| 323 |
+
return
|
| 324 |
+
|
| 325 |
+
# Append an addendum section containing every missing JD keyword
|
| 326 |
+
header = doc.add_paragraph()
|
| 327 |
+
run = header.add_run("ADDITIONAL SKILLS & KEYWORDS")
|
| 328 |
+
run.bold = True
|
| 329 |
+
run.font.size = Pt(11)
|
| 330 |
+
run.font.color.rgb = RGBColor(0x16, 0x48, 0x9E)
|
| 331 |
|
| 332 |
+
body = doc.add_paragraph(" • ".join(missing))
|
| 333 |
+
for r in body.runs:
|
| 334 |
+
r.font.size = Pt(9)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
doc.save(filepath)
|
| 337 |
except Exception:
|
|
|
|
| 388 |
|
| 389 |
# ── CORE COMPETENCIES ──
|
| 390 |
skills = customization.get("core_competencies", [])
|
| 391 |
+
if isinstance(skills, str):
|
| 392 |
+
skills = [s.strip() for s in skills.split(",") if s.strip()]
|
| 393 |
+
skills = [str(s) for s in skills] if isinstance(skills, list) else []
|
| 394 |
if skills:
|
| 395 |
self._add_section_header(doc, "CORE COMPETENCIES")
|
| 396 |
# 3-column table for skills
|
|
|
|
| 407 |
# ── WORK EXPERIENCE (from original resume) ──
|
| 408 |
self._add_section_header(doc, "PROFESSIONAL EXPERIENCE")
|
| 409 |
exp_bullets = customization.get("experience_bullets", {})
|
| 410 |
+
# Some models return a list of bullets instead of {role: [bullets]}
|
| 411 |
+
if isinstance(exp_bullets, list):
|
| 412 |
+
exp_bullets = {"_all": [str(b) for b in exp_bullets]}
|
| 413 |
+
elif not isinstance(exp_bullets, dict):
|
| 414 |
+
exp_bullets = {}
|
| 415 |
|
| 416 |
# Parse experience from original resume
|
| 417 |
exp_sections = self._extract_experience_sections(self.resume_text)
|
|
@@ -96,3 +96,66 @@ class GlassdoorScraper(BaseScraper):
|
|
| 96 |
continue
|
| 97 |
|
| 98 |
return jobs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
continue
|
| 97 |
|
| 98 |
return jobs
|
| 99 |
+
|
| 100 |
+
def get_details_bulk(self, jobs: list[Job], progress_cb=None) -> list[Job]:
|
| 101 |
+
"""Fetch descriptions for ALL jobs using ONE browser session.
|
| 102 |
+
Glassdoor previously had NO detail fetching — descriptions were always empty."""
|
| 103 |
+
need = [j for j in jobs if j.url and not j.description]
|
| 104 |
+
if not need:
|
| 105 |
+
return jobs
|
| 106 |
+
try:
|
| 107 |
+
with sync_playwright() as pw:
|
| 108 |
+
browser = pw.chromium.launch(
|
| 109 |
+
headless=True,
|
| 110 |
+
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
|
| 111 |
+
)
|
| 112 |
+
ctx = browser.new_context(
|
| 113 |
+
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
| 114 |
+
locale="en-IN",
|
| 115 |
+
)
|
| 116 |
+
page = ctx.new_page()
|
| 117 |
+
page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
| 118 |
+
for i, job in enumerate(need):
|
| 119 |
+
try:
|
| 120 |
+
page.goto(job.url, wait_until="domcontentloaded", timeout=15000)
|
| 121 |
+
page.wait_for_timeout(2000)
|
| 122 |
+
# Cloudflare challenge — wait it out (up to ~8s extra)
|
| 123 |
+
for _ in range(4):
|
| 124 |
+
if "just a moment" not in (page.title() or "").lower():
|
| 125 |
+
break
|
| 126 |
+
page.wait_for_timeout(2000)
|
| 127 |
+
soup = BeautifulSoup(page.content(), "lxml")
|
| 128 |
+
|
| 129 |
+
# 1) JSON-LD JobPosting (most reliable on Glassdoor)
|
| 130 |
+
desc = ""
|
| 131 |
+
for script in soup.find_all("script", type="application/ld+json"):
|
| 132 |
+
try:
|
| 133 |
+
import json as _json
|
| 134 |
+
data = _json.loads(script.string or "")
|
| 135 |
+
if isinstance(data, dict) and data.get("@type") == "JobPosting":
|
| 136 |
+
raw = data.get("description", "")
|
| 137 |
+
desc = BeautifulSoup(raw, "lxml").get_text(separator="\n", strip=True)
|
| 138 |
+
break
|
| 139 |
+
except Exception:
|
| 140 |
+
continue
|
| 141 |
+
# 2) Fallback: description container by class
|
| 142 |
+
if not desc:
|
| 143 |
+
desc_el = (
|
| 144 |
+
soup.find("div", class_=re.compile(r"JobDetails_jobDescription|jobDescriptionContent|desc"))
|
| 145 |
+
or soup.find("section", class_=re.compile(r"jobDescription"))
|
| 146 |
+
)
|
| 147 |
+
if desc_el:
|
| 148 |
+
desc = desc_el.get_text(separator="\n", strip=True)
|
| 149 |
+
if desc:
|
| 150 |
+
job.description = desc[:3000]
|
| 151 |
+
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
if progress_cb:
|
| 154 |
+
try:
|
| 155 |
+
progress_cb(i + 1, len(need))
|
| 156 |
+
except Exception:
|
| 157 |
+
pass
|
| 158 |
+
browser.close()
|
| 159 |
+
except Exception:
|
| 160 |
+
pass
|
| 161 |
+
return jobs
|
|
@@ -94,6 +94,46 @@ class IndeedScraper(BaseScraper):
|
|
| 94 |
|
| 95 |
return jobs
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def get_job_details(self, job: Job) -> Job:
|
| 98 |
if not job.job_id or job.description:
|
| 99 |
return job
|
|
|
|
| 94 |
|
| 95 |
return jobs
|
| 96 |
|
| 97 |
+
def get_details_bulk(self, jobs: list[Job], progress_cb=None) -> list[Job]:
|
| 98 |
+
"""Fetch descriptions for ALL jobs using ONE browser session (fast)."""
|
| 99 |
+
need = [j for j in jobs if j.job_id and not j.description]
|
| 100 |
+
if not need:
|
| 101 |
+
return jobs
|
| 102 |
+
try:
|
| 103 |
+
with sync_playwright() as pw:
|
| 104 |
+
browser = pw.chromium.launch(
|
| 105 |
+
headless=True,
|
| 106 |
+
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
|
| 107 |
+
)
|
| 108 |
+
ctx = browser.new_context(
|
| 109 |
+
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
| 110 |
+
)
|
| 111 |
+
page = ctx.new_page()
|
| 112 |
+
page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
| 113 |
+
for i, job in enumerate(need):
|
| 114 |
+
try:
|
| 115 |
+
page.goto(self.JOB_URL.format(jk=job.job_id),
|
| 116 |
+
wait_until="domcontentloaded", timeout=15000)
|
| 117 |
+
page.wait_for_timeout(1500)
|
| 118 |
+
soup = BeautifulSoup(page.content(), "lxml")
|
| 119 |
+
desc_el = (
|
| 120 |
+
soup.find("div", id="jobDescriptionText")
|
| 121 |
+
or soup.find("div", class_=re.compile(r"jobDescription|job-description"))
|
| 122 |
+
)
|
| 123 |
+
if desc_el:
|
| 124 |
+
job.description = desc_el.get_text(separator="\n", strip=True)[:3000]
|
| 125 |
+
except Exception:
|
| 126 |
+
pass
|
| 127 |
+
if progress_cb:
|
| 128 |
+
try:
|
| 129 |
+
progress_cb(i + 1, len(need))
|
| 130 |
+
except Exception:
|
| 131 |
+
pass
|
| 132 |
+
browser.close()
|
| 133 |
+
except Exception:
|
| 134 |
+
pass
|
| 135 |
+
return jobs
|
| 136 |
+
|
| 137 |
def get_job_details(self, job: Job) -> Job:
|
| 138 |
if not job.job_id or job.description:
|
| 139 |
return job
|
|
@@ -65,8 +65,9 @@ class LinkedInScraper(BaseScraper):
|
|
| 65 |
return None
|
| 66 |
|
| 67 |
url = link_el["href"].split("?")[0] if link_el else ""
|
| 68 |
-
|
| 69 |
-
|
|
|
|
| 70 |
|
| 71 |
salary_el = card.find("span", class_=re.compile(r"job-search-card__salary-info"))
|
| 72 |
date_el = card.find("time")
|
|
@@ -82,6 +83,41 @@ class LinkedInScraper(BaseScraper):
|
|
| 82 |
job_id=job_id,
|
| 83 |
)
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def get_job_details(self, job: Job) -> Job:
|
| 86 |
if not job.job_id:
|
| 87 |
return job
|
|
|
|
| 65 |
return None
|
| 66 |
|
| 67 |
url = link_el["href"].split("?")[0] if link_el else ""
|
| 68 |
+
# LinkedIn uses both /jobs/view/123456 and /jobs/view/title-slug-at-company-123456
|
| 69 |
+
m = re.search(r"/jobs/view/(\d+)", url) or re.search(r"-(\d{7,})/?$", url)
|
| 70 |
+
job_id = m.group(1) if m else ""
|
| 71 |
|
| 72 |
salary_el = card.find("span", class_=re.compile(r"job-search-card__salary-info"))
|
| 73 |
date_el = card.find("time")
|
|
|
|
| 83 |
job_id=job_id,
|
| 84 |
)
|
| 85 |
|
| 86 |
+
def get_details_bulk(self, jobs: list[Job], progress_cb=None) -> list[Job]:
|
| 87 |
+
"""Fetch descriptions for ALL jobs in parallel (HTTP, 4 workers)."""
|
| 88 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 89 |
+
need = [j for j in jobs if j.job_id and not j.description]
|
| 90 |
+
if not need:
|
| 91 |
+
return jobs
|
| 92 |
+
|
| 93 |
+
def _fetch(job):
|
| 94 |
+
url = self.DETAIL_URL.format(job_id=job.job_id)
|
| 95 |
+
resp = self._get(url)
|
| 96 |
+
if resp:
|
| 97 |
+
soup = BeautifulSoup(resp.text, "lxml")
|
| 98 |
+
desc_el = soup.find("div", class_=re.compile(r"description__text|show-more-less-html"))
|
| 99 |
+
if desc_el:
|
| 100 |
+
job.description = desc_el.get_text(separator="\n", strip=True)[:3000]
|
| 101 |
+
criteria = soup.find_all("li", class_=re.compile(r"description__job-criteria-item"))
|
| 102 |
+
for item in criteria:
|
| 103 |
+
header, value = item.find("h3"), item.find("span")
|
| 104 |
+
if header and value and ("seniority" in header.get_text(strip=True).lower()
|
| 105 |
+
or "experience" in header.get_text(strip=True).lower()):
|
| 106 |
+
job.experience_required = value.get_text(strip=True)
|
| 107 |
+
return job
|
| 108 |
+
|
| 109 |
+
done = 0
|
| 110 |
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
| 111 |
+
futures = [pool.submit(_fetch, j) for j in need]
|
| 112 |
+
for fut in as_completed(futures):
|
| 113 |
+
done += 1
|
| 114 |
+
if progress_cb:
|
| 115 |
+
try:
|
| 116 |
+
progress_cb(done, len(need))
|
| 117 |
+
except Exception:
|
| 118 |
+
pass
|
| 119 |
+
return jobs
|
| 120 |
+
|
| 121 |
def get_job_details(self, job: Job) -> Job:
|
| 122 |
if not job.job_id:
|
| 123 |
return job
|
|
@@ -432,6 +432,7 @@ with tab_search:
|
|
| 432 |
_q_progress(15, "Scraping job boards…")
|
| 433 |
all_jobs = []
|
| 434 |
seen_urls: set = set()
|
|
|
|
| 435 |
skipped_dup = 0
|
| 436 |
|
| 437 |
scraper_map = {}
|
|
@@ -457,10 +458,11 @@ with tab_search:
|
|
| 457 |
|
| 458 |
for plat_id, (pname, scraper) in scraper_map.items():
|
| 459 |
t0 = _t.time()
|
| 460 |
-
|
|
|
|
| 461 |
platform_jobs = []
|
| 462 |
-
for role_q in _jscfg["roles"]
|
| 463 |
-
for loc in _jscfg["locations"]
|
| 464 |
try:
|
| 465 |
log.info(f"Scraping {pname}: role={role_q!r} loc={loc!r}")
|
| 466 |
jobs = scraper.search(role_q, loc, max_results=_jscfg["max_jobs_per_platform"])
|
|
@@ -468,31 +470,43 @@ with tab_search:
|
|
| 468 |
for j in jobs:
|
| 469 |
if not j.url or j.url in seen_urls:
|
| 470 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
if not scraper.is_pm_role(j.title):
|
| 472 |
continue
|
| 473 |
if is_duplicate(j.url, days=dedup_days):
|
| 474 |
skipped_dup += 1
|
| 475 |
continue
|
| 476 |
seen_urls.add(j.url)
|
|
|
|
| 477 |
platform_jobs.append(j)
|
| 478 |
except Exception as e:
|
| 479 |
full_tb = _tb.format_exc()
|
| 480 |
log.error(f"{pname} scrape error ({role_q}/{loc}): {e}\n{full_tb}")
|
| 481 |
_q_log(f"⚠ {pname} ({role_q}): {str(e)[:80]}")
|
| 482 |
_t.sleep(0.5)
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
|
|
|
|
|
|
| 486 |
_progress_q.put(("step", plat_id, "active",
|
| 487 |
f"Fetching {len(needs_desc)} job descriptions…", ""))
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
all_jobs.extend(platform_jobs)
|
| 492 |
scrape_pct += pct_per_plat
|
| 493 |
_q_progress(int(scrape_pct), f"{pname}: {len(platform_jobs)} jobs")
|
| 494 |
-
_step_done(plat_id, f"{len(platform_jobs)}
|
| 495 |
-
_q_log(f"✅ {pname}: {len(platform_jobs)} jobs | {skipped_dup} dupes skipped")
|
| 496 |
|
| 497 |
_q_log(f"✅ Total unique jobs: {len(all_jobs)}")
|
| 498 |
if not all_jobs:
|
|
@@ -519,12 +533,21 @@ with tab_search:
|
|
| 519 |
top = assessed_jobs[0]
|
| 520 |
_q_log(f" Top job: {top.get('title')} @ {top.get('company')} — {top.get('relevance_score')}/10")
|
| 521 |
|
| 522 |
-
# ── Step 5: Resumes ──
|
| 523 |
t0 = _t.time()
|
| 524 |
llm_eligible = sum(1 for j in assessed_jobs if j.get("relevance_score", 0) >= _min_score)
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
customizer = ResumeCustomizer(llm, resume_text, _ocfg["resumes_dir"],
|
| 529 |
fast_model_cfg=fast_cfg)
|
| 530 |
assessed_jobs = customizer.customize_for_jobs(
|
|
@@ -532,16 +555,19 @@ with tab_search:
|
|
| 532 |
min_score_for_llm=_min_score,
|
| 533 |
max_llm_resumes=_max_resumes,
|
| 534 |
generate_all=True,
|
|
|
|
|
|
|
| 535 |
)
|
| 536 |
llm_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "LLM Tailored")
|
| 537 |
tmpl_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "Template")
|
|
|
|
| 538 |
avg_ats = ""
|
| 539 |
ats_vals = [j.get("ats_score_after") for j in assessed_jobs if j.get("ats_score_after")]
|
| 540 |
if ats_vals:
|
| 541 |
avg_ats = f" · avg ATS after: {sum(ats_vals)//len(ats_vals)}%"
|
| 542 |
-
_step_done("resumes", f"{llm_done} LLM
|
| 543 |
_q_progress(94, "Resumes generated!")
|
| 544 |
-
_q_log(f"✅ {llm_done} LLM resumes + {tmpl_done} templates{avg_ats}")
|
| 545 |
|
| 546 |
# ── Step 6: Report ──
|
| 547 |
t0 = _t.time()
|
|
@@ -706,19 +732,22 @@ with tab_results:
|
|
| 706 |
)
|
| 707 |
with dl_col2:
|
| 708 |
resume_dir = "data/output/resumes"
|
| 709 |
-
# Collect all .docx files from date subfolders
|
| 710 |
-
|
| 711 |
-
if
|
|
|
|
|
|
|
| 712 |
import io, zipfile
|
| 713 |
zip_buf = io.BytesIO()
|
| 714 |
with zipfile.ZipFile(zip_buf, "w") as zf:
|
| 715 |
-
for fp in
|
| 716 |
-
# Include date folder in zip: 2026-06-
|
| 717 |
arc_name = f"{fp.parent.name}/{fp.name}"
|
| 718 |
zf.write(str(fp), arc_name)
|
| 719 |
zip_buf.seek(0)
|
|
|
|
| 720 |
st.download_button(
|
| 721 |
-
f"⬇ Download All Resumes ({len(
|
| 722 |
zip_buf.read(),
|
| 723 |
file_name="tailored_resumes.zip",
|
| 724 |
mime="application/zip",
|
|
@@ -884,18 +913,30 @@ with tab_details:
|
|
| 884 |
with st.expander("📋 Full Job Description"):
|
| 885 |
st.text(desc[:3000])
|
| 886 |
|
| 887 |
-
# Resume download
|
| 888 |
resume_path = job.get("resume_path", "")
|
|
|
|
|
|
|
|
|
|
| 889 |
if resume_path and os.path.exists(resume_path):
|
| 890 |
st.markdown("#### 📄 Tailored Resume")
|
| 891 |
-
|
| 892 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 893 |
st.download_button(
|
| 894 |
-
|
| 895 |
f.read(),
|
| 896 |
-
file_name=
|
| 897 |
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 898 |
-
use_container_width=
|
| 899 |
)
|
| 900 |
else:
|
| 901 |
st.caption("Resume not generated for this job (score below threshold).")
|
|
|
|
| 432 |
_q_progress(15, "Scraping job boards…")
|
| 433 |
all_jobs = []
|
| 434 |
seen_urls: set = set()
|
| 435 |
+
seen_title_co: set = set() # cross-platform dedup: same job on 2 boards
|
| 436 |
skipped_dup = 0
|
| 437 |
|
| 438 |
scraper_map = {}
|
|
|
|
| 458 |
|
| 459 |
for plat_id, (pname, scraper) in scraper_map.items():
|
| 460 |
t0 = _t.time()
|
| 461 |
+
n_roles, n_locs = len(_jscfg["roles"]), len(_jscfg["locations"])
|
| 462 |
+
_step_start(plat_id, f"Searching {n_roles} roles × {n_locs} locations…")
|
| 463 |
platform_jobs = []
|
| 464 |
+
for ri, role_q in enumerate(_jscfg["roles"]):
|
| 465 |
+
for loc in _jscfg["locations"]:
|
| 466 |
try:
|
| 467 |
log.info(f"Scraping {pname}: role={role_q!r} loc={loc!r}")
|
| 468 |
jobs = scraper.search(role_q, loc, max_results=_jscfg["max_jobs_per_platform"])
|
|
|
|
| 470 |
for j in jobs:
|
| 471 |
if not j.url or j.url in seen_urls:
|
| 472 |
continue
|
| 473 |
+
tc_key = (j.title.lower().strip(), j.company.lower().strip())
|
| 474 |
+
if tc_key in seen_title_co:
|
| 475 |
+
skipped_dup += 1
|
| 476 |
+
continue
|
| 477 |
if not scraper.is_pm_role(j.title):
|
| 478 |
continue
|
| 479 |
if is_duplicate(j.url, days=dedup_days):
|
| 480 |
skipped_dup += 1
|
| 481 |
continue
|
| 482 |
seen_urls.add(j.url)
|
| 483 |
+
seen_title_co.add(tc_key)
|
| 484 |
platform_jobs.append(j)
|
| 485 |
except Exception as e:
|
| 486 |
full_tb = _tb.format_exc()
|
| 487 |
log.error(f"{pname} scrape error ({role_q}/{loc}): {e}\n{full_tb}")
|
| 488 |
_q_log(f"⚠ {pname} ({role_q}): {str(e)[:80]}")
|
| 489 |
_t.sleep(0.5)
|
| 490 |
+
_progress_q.put(("step", plat_id, "active",
|
| 491 |
+
f"Role {ri+1}/{n_roles} done — {len(platform_jobs)} jobs so far", ""))
|
| 492 |
+
# Fetch descriptions for ALL jobs (bulk = one browser / parallel HTTP)
|
| 493 |
+
needs_desc = [j for j in platform_jobs if not j.description]
|
| 494 |
+
if needs_desc and hasattr(scraper, "get_details_bulk"):
|
| 495 |
_progress_q.put(("step", plat_id, "active",
|
| 496 |
f"Fetching {len(needs_desc)} job descriptions…", ""))
|
| 497 |
+
def _desc_cb(done, tot, _pid=plat_id):
|
| 498 |
+
_progress_q.put(("step", _pid, "active",
|
| 499 |
+
f"Descriptions: {done}/{tot}", ""))
|
| 500 |
+
try:
|
| 501 |
+
scraper.get_details_bulk(platform_jobs, progress_cb=_desc_cb)
|
| 502 |
+
except Exception as e:
|
| 503 |
+
log.error(f"{pname} bulk details error: {e}\n{_tb.format_exc()}")
|
| 504 |
+
n_with_desc = sum(1 for j in platform_jobs if j.description)
|
| 505 |
all_jobs.extend(platform_jobs)
|
| 506 |
scrape_pct += pct_per_plat
|
| 507 |
_q_progress(int(scrape_pct), f"{pname}: {len(platform_jobs)} jobs")
|
| 508 |
+
_step_done(plat_id, f"{len(platform_jobs)} PM jobs · {n_with_desc} with JD", t0)
|
| 509 |
+
_q_log(f"✅ {pname}: {len(platform_jobs)} jobs ({n_with_desc} with JD) | {skipped_dup} dupes skipped")
|
| 510 |
|
| 511 |
_q_log(f"✅ Total unique jobs: {len(all_jobs)}")
|
| 512 |
if not all_jobs:
|
|
|
|
| 533 |
top = assessed_jobs[0]
|
| 534 |
_q_log(f" Top job: {top.get('title')} @ {top.get('company')} — {top.get('relevance_score')}/10")
|
| 535 |
|
| 536 |
+
# ── Step 5: Resumes (PARALLEL across model pool) ──
|
| 537 |
t0 = _t.time()
|
| 538 |
llm_eligible = sum(1 for j in assessed_jobs if j.get("relevance_score", 0) >= _min_score)
|
| 539 |
+
n_to_gen = min(llm_eligible, _max_resumes)
|
| 540 |
+
phase2_cfgs = [m for m in ASSESSMENT_MODELS if m.get("phase2") and m.get("api_key")]
|
| 541 |
+
_step_start("resumes", f"Tailoring {n_to_gen} resumes across {min(6, len(phase2_cfgs))} models…")
|
| 542 |
+
_q_progress(80, "Generating ATS-optimized resumes (parallel)…")
|
| 543 |
+
_q_log(f"📝 Generating {n_to_gen} LLM resumes in parallel + rest template")
|
| 544 |
+
|
| 545 |
+
def _resume_cb(done, tot, msg):
|
| 546 |
+
pct = 80 + int(14 * done / max(1, tot))
|
| 547 |
+
_q_progress(pct, f"Resumes: {done}/{tot}")
|
| 548 |
+
_progress_q.put(("step", "resumes", "active", f"{done}/{tot} — {msg[:70]}", ""))
|
| 549 |
+
_q_log(f" {msg[:100]}")
|
| 550 |
+
|
| 551 |
customizer = ResumeCustomizer(llm, resume_text, _ocfg["resumes_dir"],
|
| 552 |
fast_model_cfg=fast_cfg)
|
| 553 |
assessed_jobs = customizer.customize_for_jobs(
|
|
|
|
| 555 |
min_score_for_llm=_min_score,
|
| 556 |
max_llm_resumes=_max_resumes,
|
| 557 |
generate_all=True,
|
| 558 |
+
model_cfgs=phase2_cfgs,
|
| 559 |
+
progress_cb=_resume_cb,
|
| 560 |
)
|
| 561 |
llm_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "LLM Tailored")
|
| 562 |
tmpl_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "Template")
|
| 563 |
+
pdf_done = sum(1 for j in assessed_jobs if j.get("resume_pdf_path"))
|
| 564 |
avg_ats = ""
|
| 565 |
ats_vals = [j.get("ats_score_after") for j in assessed_jobs if j.get("ats_score_after")]
|
| 566 |
if ats_vals:
|
| 567 |
avg_ats = f" · avg ATS after: {sum(ats_vals)//len(ats_vals)}%"
|
| 568 |
+
_step_done("resumes", f"{llm_done} LLM + {tmpl_done} template · {pdf_done} PDFs{avg_ats}", t0)
|
| 569 |
_q_progress(94, "Resumes generated!")
|
| 570 |
+
_q_log(f"✅ {llm_done} LLM resumes + {tmpl_done} templates · {pdf_done} PDFs{avg_ats}")
|
| 571 |
|
| 572 |
# ── Step 6: Report ──
|
| 573 |
t0 = _t.time()
|
|
|
|
| 732 |
)
|
| 733 |
with dl_col2:
|
| 734 |
resume_dir = "data/output/resumes"
|
| 735 |
+
# Collect all .docx AND .pdf files from date subfolders
|
| 736 |
+
all_files = []
|
| 737 |
+
if os.path.isdir(resume_dir):
|
| 738 |
+
all_files = list(Path(resume_dir).rglob("*.docx")) + list(Path(resume_dir).rglob("*.pdf"))
|
| 739 |
+
if all_files:
|
| 740 |
import io, zipfile
|
| 741 |
zip_buf = io.BytesIO()
|
| 742 |
with zipfile.ZipFile(zip_buf, "w") as zf:
|
| 743 |
+
for fp in all_files:
|
| 744 |
+
# Include date folder in zip: 2026-06-12/Company_Title.pdf
|
| 745 |
arc_name = f"{fp.parent.name}/{fp.name}"
|
| 746 |
zf.write(str(fp), arc_name)
|
| 747 |
zip_buf.seek(0)
|
| 748 |
+
n_pdf = sum(1 for f in all_files if f.suffix == ".pdf")
|
| 749 |
st.download_button(
|
| 750 |
+
f"⬇ Download All Resumes ({len(all_files)} files, {n_pdf} PDFs)",
|
| 751 |
zip_buf.read(),
|
| 752 |
file_name="tailored_resumes.zip",
|
| 753 |
mime="application/zip",
|
|
|
|
| 913 |
with st.expander("📋 Full Job Description"):
|
| 914 |
st.text(desc[:3000])
|
| 915 |
|
| 916 |
+
# Resume download (PDF preferred for manual applications + DOCX)
|
| 917 |
resume_path = job.get("resume_path", "")
|
| 918 |
+
pdf_path = job.get("resume_pdf_path", "") or (
|
| 919 |
+
os.path.splitext(resume_path)[0] + ".pdf" if resume_path else ""
|
| 920 |
+
)
|
| 921 |
if resume_path and os.path.exists(resume_path):
|
| 922 |
st.markdown("#### 📄 Tailored Resume")
|
| 923 |
+
rcol1, rcol2 = st.columns(2)
|
| 924 |
+
if pdf_path and os.path.exists(pdf_path):
|
| 925 |
+
with rcol1, open(pdf_path, "rb") as f:
|
| 926 |
+
st.download_button(
|
| 927 |
+
"⬇ Download PDF (for applying)",
|
| 928 |
+
f.read(),
|
| 929 |
+
file_name=os.path.basename(pdf_path),
|
| 930 |
+
mime="application/pdf",
|
| 931 |
+
use_container_width=True,
|
| 932 |
+
)
|
| 933 |
+
with rcol2, open(resume_path, "rb") as f:
|
| 934 |
st.download_button(
|
| 935 |
+
"⬇ Download DOCX (editable)",
|
| 936 |
f.read(),
|
| 937 |
+
file_name=os.path.basename(resume_path),
|
| 938 |
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 939 |
+
use_container_width=True,
|
| 940 |
)
|
| 941 |
else:
|
| 942 |
st.caption("Resume not generated for this job (score below threshold).")
|