""" 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": "", "contact": { "phone": "", "email": "", "linkedin": "", "website": "", "location": "" }, "summary": "<4-6 sentence summary. Opens with: 'Strong-fit candidate for [role] at [company]: ...' Weave 8+ JD keywords naturally.>", "roles": [ { "title": "", "company": "", "location": "", "dates": "", "bullets": [ "", "<5-7 bullets per role; pick the best across all original sub-projects>" ] } ], "achievements": [ "<3-5 top quantified highlights cutting across roles>" ], "education": [ {"degree": "", "institution": "", "dates": ""} ] } """