File size: 10,209 Bytes
0e70529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689bd71
0e70529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689bd71
 
0e70529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47b43eb
0e70529
 
 
 
 
 
 
 
 
 
 
 
47b43eb
0e70529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689bd71
0e70529
 
47b43eb
0e70529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47b43eb
 
 
 
 
 
0e70529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689bd71
 
0e70529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
"""
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>"}
  ]
}
"""