JAA-ATS-Tool / src /resume_model.py
saitejatirunagari's picture
Add address/location to resume contact header for ATS checks
689bd71
Raw
History Blame
10.2 kB
"""
Canonical Resume data model β€” the SINGLE source of truth that drives both
the LLM tailoring contract (v4) and the canonical renderer.
Design principles:
- Flat list of bullets per role (no sub-sections, no Β§Β§HEADERΒ§Β§ markers)
- 5-7 bullets per role max β€” the LLM selects the best per JD
- No Skills/Competencies section (per project policy R6)
- Keywords live in `summary` + per-bullet text only
- One canonical visual format applied to every tailored resume
Phase 4 β€” adopted after the user approved Option A on 2026-06-16.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field, asdict
from typing import Optional
@dataclass
class Contact:
"""Candidate's contact line β€” rendered as one centered row under the name."""
phone: str = ""
email: str = ""
linkedin: str = ""
website: str = ""
location: str = "" # City, State, Country β€” needed for ATS "address" checks
def render_line(self) -> str:
"""Format as a single dot-separated line for the resume header."""
parts = []
if self.phone:
parts.append(self.phone)
if self.email:
parts.append(self.email)
if self.linkedin:
# Strip protocol for cleanness; the link layer adds it back
ln = self.linkedin.replace("https://", "").replace("http://", "")
parts.append(ln)
if self.website:
ws = self.website.replace("https://", "").replace("http://", "")
parts.append(ws)
if self.location:
parts.append(self.location)
return " Β· ".join(parts)
@dataclass
class Role:
"""One employment entry."""
title: str
company: str
location: str = ""
dates: str = "" # e.g. "Jan 2023 – Present"
bullets: list[str] = field(default_factory=list)
@dataclass
class Education:
"""One education entry."""
degree: str # e.g. "Diploma β€” Product & Brand Management"
institution: str # e.g. "IIM Rohtak"
dates: str = "" # e.g. "Mar 2023 – Sep 2023"
@dataclass
class Resume:
"""The complete canonical resume."""
name: str
contact: Contact = field(default_factory=Contact)
summary: str = "" # 4-6 sentences, opens with recruiter pitch
skills: list[str] = field(default_factory=list) # flat skill list; renderer groups into a categorized SKILLS section
roles: list[Role] = field(default_factory=list)
achievements: list[str] = field(default_factory=list)
education: list[Education] = field(default_factory=list)
# ──────────────────────────────────────────────────────────────────
# Serialization (LLM round-trip)
# ──────────────────────────────────────────────────────────────────
def to_dict(self) -> dict:
return {
"name": self.name,
"contact": asdict(self.contact),
"summary": self.summary,
"skills": list(self.skills),
"roles": [asdict(r) for r in self.roles],
"achievements": list(self.achievements),
"education": [asdict(e) for e in self.education],
}
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
@classmethod
def from_dict(cls, data: dict) -> "Resume":
c = data.get("contact") or {}
return cls(
name=data.get("name", "Your Name"),
contact=Contact(
phone=c.get("phone", ""),
email=c.get("email", ""),
linkedin=c.get("linkedin", ""),
website=c.get("website", ""),
location=c.get("location", ""),
),
summary=data.get("summary", ""),
skills=[str(s) for s in (data.get("skills") or []) if str(s).strip()],
roles=[
Role(
title=r.get("title", ""),
company=r.get("company", ""),
location=r.get("location", ""),
dates=r.get("dates", ""),
bullets=[str(b) for b in (r.get("bullets") or []) if str(b).strip()],
)
for r in (data.get("roles") or [])
],
achievements=[str(a) for a in (data.get("achievements") or []) if str(a).strip()],
education=[
Education(
degree=e.get("degree", ""),
institution=e.get("institution", ""),
dates=e.get("dates", ""),
)
for e in (data.get("education") or [])
],
)
# ──────────────────────────────────────────────────────────────────
# Flat-text view (used by the ATS scorer)
# ──────────────────────────────────────────────────────────────────
def to_flat_text(self) -> str:
"""Plain-text rendering for keyword matching / ATS scoring."""
parts = [self.name]
cl = self.contact.render_line()
if cl:
parts.append(cl)
if self.summary:
parts.append("PROFESSIONAL SUMMARY")
parts.append(self.summary)
if self.skills:
parts.append("SKILLS")
# Emit in chunks of 8 (mirrors the renderer's categorized lines) so
# no single line trips the scorer's anti-spam strip (15+ separators).
for i in range(0, len(self.skills), 8):
parts.append(", ".join(self.skills[i:i + 8]))
if self.roles:
parts.append("PROFESSIONAL EXPERIENCE")
for r in self.roles:
parts.append(f"{r.title}")
meta = " Β· ".join(filter(None, [r.company, r.location]))
if meta:
parts.append(meta)
if r.dates:
parts.append(r.dates)
for b in r.bullets:
parts.append(f"β€’ {b}")
if self.achievements:
parts.append("KEY ACHIEVEMENTS")
for a in self.achievements:
parts.append(f"β€’ {a}")
if self.education:
parts.append("EDUCATION")
for e in self.education:
parts.append(e.degree)
meta = " Β· ".join(filter(None, [e.institution, e.dates]))
if meta:
parts.append(meta)
return "\n".join(parts)
# ─────────────────────────────────────────────────────────────────────────
# Validation helpers
# ─────────────────────────────────────────────────────────────────────────
def validate_resume(resume: Resume, strict: bool = False) -> list[str]:
"""
Return a list of validation warnings. Empty list = resume is well-formed.
If `strict=True`, also enforces canonical format limits (5-7 bullets/role,
name/contact/summary present, etc.). Used in the renderer to flag issues
before writing.
"""
issues: list[str] = []
if not resume.name or resume.name == "Your Name":
issues.append("name is missing or placeholder")
if not resume.contact.email and not resume.contact.phone:
issues.append("contact has no email or phone")
if not resume.summary or len(resume.summary) < 100:
issues.append(f"summary is too short ({len(resume.summary)} chars; min 100)")
if not resume.roles:
issues.append("no roles in experience section")
if strict:
for i, role in enumerate(resume.roles):
n = len(role.bullets)
if n < 2:
issues.append(f"role {i} ({role.title}) has only {n} bullets β€” recommend 3+")
elif n > 8:
issues.append(f"role {i} ({role.title}) has {n} bullets β€” recommend 5-7")
if not resume.education:
issues.append("no education entries (recommended)")
return issues
# ─────────────────────────────────────────────────────────────────────────
# JSON Schema (for LLM prompt embedding)
# ─────────────────────────────────────────────────────────────────────────
RESUME_JSON_SCHEMA_DESCRIPTION = """\
{
"name": "<full name>",
"contact": {
"phone": "<phone>",
"email": "<email>",
"linkedin": "<linkedin URL>",
"website": "<optional personal site>",
"location": "<city, state, country β€” keep the candidate's existing value verbatim>"
},
"summary": "<4-6 sentence summary. Opens with: 'Strong-fit candidate for [role] at [company]: ...' Weave 8+ JD keywords naturally.>",
"roles": [
{
"title": "<job title>",
"company": "<company name>",
"location": "<city, country>",
"dates": "<e.g. Jan 2023 – Present>",
"bullets": [
"<Action-verb-start bullet with quantified impact and JD keywords>",
"<5-7 bullets per role; pick the best across all original sub-projects>"
]
}
],
"achievements": [
"<3-5 top quantified highlights cutting across roles>"
],
"education": [
{"degree": "<Degree β€” Specialization>", "institution": "<School>", "dates": "<date range>"}
]
}
"""