Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| 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) | |
| 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) | |
| 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" | |
| 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) | |
| 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>"} | |
| ] | |
| } | |
| """ | |