saitejatirunagari Cursor commited on
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 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
- cache_key = f"{stat.st_mtime_ns}_{stat.st_size}"
 
 
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
- return Resume.from_dict(cached["resume"])
 
 
 
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