Spaces:
Sleeping
Sleeping
Commit Β·
689bd71
1
Parent(s): d5041af
Add address/location to resume contact header for ATS checks
Browse files- Add a `location` field to the Contact model and render it on the header line, and document it in the LLM resume schema (Option A)
- Parse a City/State/Country line from the resume when present, else fall back to a configurable CONTACT_LOCATION (default: Hyderabad, Telangana, India - Open to relocate) (Option B)
- Preserve the base location through LLM tailoring so it survives across every provider; bump the parse-cache version so existing caches are rebuilt with the address
Co-authored-by: Cursor <cursoragent@cursor.com>
- config.py +8 -0
- src/resume_customizer.py +4 -0
- src/resume_model.py +6 -1
- src/resume_parser_v2.py +36 -2
config.py
CHANGED
|
@@ -5,6 +5,14 @@ load_dotenv()
|
|
| 5 |
|
| 6 |
NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
# Primary key β GLM 5.1 (most reliable, used for resume parsing + customization)
|
| 9 |
NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
|
| 10 |
GLM_BASE_URL = NVIDIA_BASE_URL
|
|
|
|
| 5 |
|
| 6 |
NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
| 7 |
|
| 8 |
+
# ββ Candidate contact ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9 |
+
# Resume-header address line. ATS checkers (Jobalytics / Resume Worded) flag a
|
| 10 |
+
# missing address; a "City, State, Country" line satisfies that check. Used as
|
| 11 |
+
# the fallback when the uploaded resume has no parseable location.
|
| 12 |
+
CONTACT_LOCATION = os.getenv(
|
| 13 |
+
"CONTACT_LOCATION", "Hyderabad, Telangana, India Β· Open to relocate"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
# Primary key β GLM 5.1 (most reliable, used for resume parsing + customization)
|
| 17 |
NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
|
| 18 |
GLM_BASE_URL = NVIDIA_BASE_URL
|
src/resume_customizer.py
CHANGED
|
@@ -1348,6 +1348,10 @@ class ResumeCustomizer:
|
|
| 1348 |
tailored.name = base_resume.name
|
| 1349 |
if not tailored.contact.email and not tailored.contact.phone:
|
| 1350 |
tailored.contact = base_resume.contact
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1351 |
|
| 1352 |
# 2. Restore dropped roles. If the LLM returned fewer roles than the
|
| 1353 |
# base resume has, append the missing ones with original bullets.
|
|
|
|
| 1348 |
tailored.name = base_resume.name
|
| 1349 |
if not tailored.contact.email and not tailored.contact.phone:
|
| 1350 |
tailored.contact = base_resume.contact
|
| 1351 |
+
# Contact is identity β never let the model drop the address line
|
| 1352 |
+
# (keeps the ATS "address" check green across every provider).
|
| 1353 |
+
if not tailored.contact.location:
|
| 1354 |
+
tailored.contact.location = base_resume.contact.location
|
| 1355 |
|
| 1356 |
# 2. Restore dropped roles. If the LLM returned fewer roles than the
|
| 1357 |
# base resume has, append the missing ones with original bullets.
|
src/resume_model.py
CHANGED
|
@@ -26,6 +26,7 @@ class Contact:
|
|
| 26 |
email: str = ""
|
| 27 |
linkedin: str = ""
|
| 28 |
website: str = ""
|
|
|
|
| 29 |
|
| 30 |
def render_line(self) -> str:
|
| 31 |
"""Format as a single dot-separated line for the resume header."""
|
|
@@ -41,6 +42,8 @@ class Contact:
|
|
| 41 |
if self.website:
|
| 42 |
ws = self.website.replace("https://", "").replace("http://", "")
|
| 43 |
parts.append(ws)
|
|
|
|
|
|
|
| 44 |
return " Β· ".join(parts)
|
| 45 |
|
| 46 |
|
|
@@ -100,6 +103,7 @@ class Resume:
|
|
| 100 |
email=c.get("email", ""),
|
| 101 |
linkedin=c.get("linkedin", ""),
|
| 102 |
website=c.get("website", ""),
|
|
|
|
| 103 |
),
|
| 104 |
summary=data.get("summary", ""),
|
| 105 |
skills=[str(s) for s in (data.get("skills") or []) if str(s).strip()],
|
|
@@ -218,7 +222,8 @@ RESUME_JSON_SCHEMA_DESCRIPTION = """\
|
|
| 218 |
"phone": "<phone>",
|
| 219 |
"email": "<email>",
|
| 220 |
"linkedin": "<linkedin URL>",
|
| 221 |
-
"website": "<optional personal site>"
|
|
|
|
| 222 |
},
|
| 223 |
"summary": "<4-6 sentence summary. Opens with: 'Strong-fit candidate for [role] at [company]: ...' Weave 8+ JD keywords naturally.>",
|
| 224 |
"roles": [
|
|
|
|
| 26 |
email: str = ""
|
| 27 |
linkedin: str = ""
|
| 28 |
website: str = ""
|
| 29 |
+
location: str = "" # City, State, Country β needed for ATS "address" checks
|
| 30 |
|
| 31 |
def render_line(self) -> str:
|
| 32 |
"""Format as a single dot-separated line for the resume header."""
|
|
|
|
| 42 |
if self.website:
|
| 43 |
ws = self.website.replace("https://", "").replace("http://", "")
|
| 44 |
parts.append(ws)
|
| 45 |
+
if self.location:
|
| 46 |
+
parts.append(self.location)
|
| 47 |
return " Β· ".join(parts)
|
| 48 |
|
| 49 |
|
|
|
|
| 103 |
email=c.get("email", ""),
|
| 104 |
linkedin=c.get("linkedin", ""),
|
| 105 |
website=c.get("website", ""),
|
| 106 |
+
location=c.get("location", ""),
|
| 107 |
),
|
| 108 |
summary=data.get("summary", ""),
|
| 109 |
skills=[str(s) for s in (data.get("skills") or []) if str(s).strip()],
|
|
|
|
| 222 |
"phone": "<phone>",
|
| 223 |
"email": "<email>",
|
| 224 |
"linkedin": "<linkedin URL>",
|
| 225 |
+
"website": "<optional personal site>",
|
| 226 |
+
"location": "<city, state, country β keep the candidate's existing value verbatim>"
|
| 227 |
},
|
| 228 |
"summary": "<4-6 sentence summary. Opens with: 'Strong-fit candidate for [role] at [company]: ...' Weave 8+ JD keywords naturally.>",
|
| 229 |
"roles": [
|
src/resume_parser_v2.py
CHANGED
|
@@ -21,6 +21,11 @@ from .resume_customizer import (
|
|
| 21 |
_extract_candidate_name, _normalize_spaced_text, _read_docx_text,
|
| 22 |
)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
_DATE_PATTERN = re.compile(
|
| 26 |
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4}\s*[-ββto]+\s*"
|
|
@@ -70,14 +75,19 @@ def parse_resume_pdf_cached(pdf_path: str, cache_path: str = "data/resume/_parse
|
|
| 70 |
if not os.path.exists(pdf_path):
|
| 71 |
raise FileNotFoundError(pdf_path)
|
| 72 |
stat = os.stat(pdf_path)
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
|
| 75 |
if os.path.exists(cache_path):
|
| 76 |
try:
|
| 77 |
with open(cache_path, encoding="utf-8") as f:
|
| 78 |
cached = json.load(f)
|
| 79 |
if cached.get("_cache_key") == cache_key:
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 81 |
except Exception:
|
| 82 |
pass # Cache invalid, re-parse
|
| 83 |
|
|
@@ -93,6 +103,27 @@ def parse_resume_pdf_cached(pdf_path: str, cache_path: str = "data/resume/_parse
|
|
| 93 |
# Section extractors
|
| 94 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def _extract_contact(text: str) -> Contact:
|
| 97 |
email_m = re.search(r"[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}", text)
|
| 98 |
phone_m = re.search(r"[\+]?[0-9]{1,4}[\s.-]?[0-9]{4,5}[\s.-]?[0-9]{4,5}", text)
|
|
@@ -101,6 +132,9 @@ def _extract_contact(text: str) -> Contact:
|
|
| 101 |
phone=phone_m.group() if phone_m else "",
|
| 102 |
email=email_m.group() if email_m else "",
|
| 103 |
linkedin=("https://" + linkedin_m.group()) if linkedin_m else "",
|
|
|
|
|
|
|
|
|
|
| 104 |
)
|
| 105 |
|
| 106 |
|
|
|
|
| 21 |
_extract_candidate_name, _normalize_spaced_text, _read_docx_text,
|
| 22 |
)
|
| 23 |
|
| 24 |
+
try:
|
| 25 |
+
from config import CONTACT_LOCATION
|
| 26 |
+
except Exception: # pragma: no cover - config always present in app runtime
|
| 27 |
+
CONTACT_LOCATION = "Hyderabad, Telangana, India Β· Open to relocate"
|
| 28 |
+
|
| 29 |
|
| 30 |
_DATE_PATTERN = re.compile(
|
| 31 |
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4}\s*[-ββto]+\s*"
|
|
|
|
| 75 |
if not os.path.exists(pdf_path):
|
| 76 |
raise FileNotFoundError(pdf_path)
|
| 77 |
stat = os.stat(pdf_path)
|
| 78 |
+
# cache version bumped to v2 when the contact `location` field was added, so
|
| 79 |
+
# pre-existing caches (without an address) are rebuilt.
|
| 80 |
+
cache_key = f"v2_{stat.st_mtime_ns}_{stat.st_size}"
|
| 81 |
|
| 82 |
if os.path.exists(cache_path):
|
| 83 |
try:
|
| 84 |
with open(cache_path, encoding="utf-8") as f:
|
| 85 |
cached = json.load(f)
|
| 86 |
if cached.get("_cache_key") == cache_key:
|
| 87 |
+
resume = Resume.from_dict(cached["resume"])
|
| 88 |
+
if not resume.contact.location:
|
| 89 |
+
resume.contact.location = CONTACT_LOCATION
|
| 90 |
+
return resume
|
| 91 |
except Exception:
|
| 92 |
pass # Cache invalid, re-parse
|
| 93 |
|
|
|
|
| 103 |
# Section extractors
|
| 104 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
|
| 106 |
+
def _extract_location(text: str) -> str:
|
| 107 |
+
"""Best-effort: find a 'City, State, Country' line near the top of the resume.
|
| 108 |
+
|
| 109 |
+
Conservative on purpose β only accepts a short, comma-bearing header line so
|
| 110 |
+
we don't mistake a sentence for an address. Returns "" when nothing matches,
|
| 111 |
+
and the caller falls back to the configured CONTACT_LOCATION.
|
| 112 |
+
"""
|
| 113 |
+
for line in text.splitlines()[:10]:
|
| 114 |
+
s = line.strip().strip("|β’Β·-").strip()
|
| 115 |
+
if not (3 <= len(s) <= 60) or "," not in s:
|
| 116 |
+
continue
|
| 117 |
+
if re.search(r"@|https?://|linkedin\.com|\d{6,}", s, re.I):
|
| 118 |
+
continue # skip email / url / phone lines
|
| 119 |
+
# India-based or a generic "City, Region(, Country)" shape
|
| 120 |
+
if re.search(r"\bindia\b", s, re.I) or re.match(
|
| 121 |
+
r"^[A-Z][a-zA-Z.]+(?:\s[A-Z][a-zA-Z.]+)*,\s*[A-Z][a-zA-Z.]+", s
|
| 122 |
+
):
|
| 123 |
+
return s
|
| 124 |
+
return ""
|
| 125 |
+
|
| 126 |
+
|
| 127 |
def _extract_contact(text: str) -> Contact:
|
| 128 |
email_m = re.search(r"[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}", text)
|
| 129 |
phone_m = re.search(r"[\+]?[0-9]{1,4}[\s.-]?[0-9]{4,5}[\s.-]?[0-9]{4,5}", text)
|
|
|
|
| 132 |
phone=phone_m.group() if phone_m else "",
|
| 133 |
email=email_m.group() if email_m else "",
|
| 134 |
linkedin=("https://" + linkedin_m.group()) if linkedin_m else "",
|
| 135 |
+
# Option B: use the resume's own location if present, else the
|
| 136 |
+
# configured fallback so the ATS "address" check always passes.
|
| 137 |
+
location=_extract_location(text) or CONTACT_LOCATION,
|
| 138 |
)
|
| 139 |
|
| 140 |
|