File size: 14,483 Bytes
7ff6662
 
 
 
 
 
 
 
b15fd58
 
 
7ff6662
 
 
 
b15fd58
 
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b15fd58
 
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b15fd58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b15fd58
 
 
 
 
 
7ff6662
b15fd58
 
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
import json
import re
import time
from openai import OpenAI
from config import NVIDIA_API_KEY, GLM_BASE_URL, GLM_MODEL


class LLMClient:
    # Hard cap per API request β€” a hung call must fail fast, not stall the pipeline
    REQUEST_TIMEOUT = 90.0

    def __init__(self):
        self.client = OpenAI(
            base_url=GLM_BASE_URL,
            api_key=NVIDIA_API_KEY,
            timeout=self.REQUEST_TIMEOUT,
            max_retries=0,  # we do our own retries with backoff
        )
        self.model = GLM_MODEL

    def _call(self, system: str, user: str, max_tokens: int = 512, retries: int = 3) -> str:
        for attempt in range(retries):
            try:
                completion = self.client.chat.completions.create(
                    model=self.model,
                    messages=[
                        {"role": "system", "content": system},
                        {"role": "user", "content": user},
                    ],
                    temperature=0.2,
                    top_p=0.9,
                    max_tokens=max_tokens,
                    stream=False,
                )
                return completion.choices[0].message.content or ""
            except Exception as e:
                if attempt < retries - 1:
                    time.sleep(2 ** attempt)
                else:
                    raise

    def _extract_json(self, text: str) -> dict | list:
        try:
            return json.loads(text)
        except Exception:
            pass
        match = re.search(r"```(?:json)?\s*([\s\S]+?)```", text)
        if match:
            try:
                return json.loads(match.group(1))
            except Exception:
                pass
        for start_char, end_char in [('{', '}'), ('[', ']')]:
            idx = text.find(start_char)
            if idx >= 0:
                depth = 0
                for i, ch in enumerate(text[idx:], idx):
                    if ch == start_char:
                        depth += 1
                    elif ch == end_char:
                        depth -= 1
                    if depth == 0:
                        try:
                            return json.loads(text[idx:i+1])
                        except Exception:
                            break
        raise ValueError(f"Cannot parse JSON: {text[:200]}")

    # ──────────────────────────────────────────────────────────
    # BATCH ASSESSMENT β€” sends 8 jobs per API call (8Γ— faster)
    # ──────────────────────────────────────────────────────────
    def assess_jobs_batch(self, jobs_batch: list[dict], compact_profile: str) -> list[dict]:
        """
        Assess a batch of up to 8 jobs in one API call.
        Returns list of assessment dicts in the same order as jobs_batch.
        """
        system = (
            "You are a PM recruiter. Rate job-candidate fit. "
            "Return ONLY a JSON array β€” no markdown, no text."
        )

        jobs_text = ""
        for i, job in enumerate(jobs_batch, 1):
            desc = (job.get("description") or "")[:300].replace("\n", " ")
            jobs_text += (
                f"\nJOB {i}: {job.get('title','')} at {job.get('company','')} | {job.get('location','')}\n"
                f"DESC: {desc}\n"
            )

        user = f"""CANDIDATE: {compact_profile}

{jobs_text}
Return a JSON array with one object per job (in order):
[
  {{
    "job_index": 1,
    "score": <1-10>,
    "match_pct": <0-100>,
    "exp_match": "Good fit|Under-qualified|Over-qualified",
    "matching": ["skill1","skill2"],
    "missing": ["skill1"],
    "strengths": ["point1","point2"],
    "note": "<1 sentence>",
    "keywords": ["kw1","kw2","kw3"],
    "priority": "High|Medium|Low"
  }}
]"""

        response = self._call(system, user, max_tokens=150 * len(jobs_batch))
        try:
            result = self._extract_json(response)
            if isinstance(result, list):
                return result
            # Sometimes model wraps in object
            if isinstance(result, dict):
                for v in result.values():
                    if isinstance(v, list):
                        return v
        except Exception:
            pass
        # Fallback: return neutral scores
        return [self._neutral_assessment(i + 1) for i in range(len(jobs_batch))]

    def _neutral_assessment(self, idx: int) -> dict:
        return {
            "job_index": idx, "score": 5, "match_pct": 50,
            "exp_match": "Unknown", "matching": [], "missing": [],
            "strengths": [], "note": "Auto-assessment failed.",
            "keywords": [], "priority": "Medium",
        }

    def _call_with_cfg(self, cfg: dict, system: str, user: str, max_tokens: int = 2000) -> str:
        """Call any NVIDIA model using the provided model config dict."""
        from openai import OpenAI
        client = OpenAI(base_url=cfg.get("base_url"), api_key=cfg["api_key"],
                        timeout=self.REQUEST_TIMEOUT, max_retries=0)
        extra_body = cfg.get("extra_body") or None
        for attempt in range(3):
            try:
                kwargs = dict(
                    model=cfg["model"],
                    messages=[
                        {"role": "system", "content": system},
                        {"role": "user",   "content": user},
                    ],
                    temperature=0.2,
                    top_p=0.9,
                    max_tokens=max_tokens,
                    stream=False,
                )
                if extra_body:
                    kwargs["extra_body"] = extra_body
                completion = client.chat.completions.create(**kwargs)
                return completion.choices[0].message.content or ""
            except Exception:
                if attempt < 2:
                    time.sleep(2 ** attempt)
                else:
                    raise

    def customize_resume_fast(self, cfg: dict, resume_text: str, job_description: str,
                               job_title: str, company: str, assessment: dict) -> dict:
        """Customize resume using a fast model (Kimi/Step/Qwen) instead of GLM."""
        system = (
            "You are an ATS resume writer for PM roles. "
            "Return ONLY valid JSON, no markdown."
        )
        matching_skills = ", ".join(assessment.get("matching_skills", assessment.get("matching", [])))
        ats_keywords    = ", ".join(assessment.get("ats_keywords", assessment.get("keywords", [])))

        user = self._resume_customize_prompt(
            resume_text, job_description, job_title, company, ats_keywords, matching_skills
        )
        for attempt in range(2):
            try:
                response = self._call_with_cfg(cfg, system, user, max_tokens=4000)
                data = self._extract_json(response)
                # Some models wrap the object in an array
                if isinstance(data, list):
                    data = next((d for d in data if isinstance(d, dict)), {})
                if self._customization_valid(data):
                    return data
            except Exception:
                pass
        return self._empty_customization()

    @staticmethod
    def _customization_valid(data) -> bool:
        """A usable customization must have a real summary and skills list."""
        return (
            isinstance(data, dict)
            and len(data.get("professional_summary", "") or "") > 50
            and len(data.get("core_competencies", []) or []) >= 5
        )

    # ──────────────────────────────────────────────────────────
    # RESUME CUSTOMIZATION
    # ──────────────────────────────────────────────────────────
    def customize_resume(self, resume_text: str, job_description: str, job_title: str, company: str, assessment: dict) -> dict:
        system = (
            "You are an ATS resume writer for PM roles. "
            "Return ONLY valid JSON, no markdown."
        )
        matching_skills = ", ".join(assessment.get("matching_skills", assessment.get("matching", [])))
        ats_keywords    = ", ".join(assessment.get("ats_keywords", assessment.get("keywords", [])))
        user = self._resume_customize_prompt(
            resume_text, job_description, job_title, company, ats_keywords, matching_skills
        )
        try:
            response = self._call(system, user, max_tokens=4000)
            data = self._extract_json(response)
            if isinstance(data, list):
                data = next((d for d in data if isinstance(d, dict)), {})
            if self._customization_valid(data):
                return data
        except Exception:
            pass
        return self._empty_customization()

    def _resume_customize_prompt(self, resume_text, job_description, job_title, company,
                                  ats_keywords, matching_skills) -> str:
        kw_list = ats_keywords if ats_keywords else "product manager, agile, roadmap, stakeholder, KPI, user research"
        return f"""You are an expert ATS resume writer. Rewrite this resume to score 95%+ on ATS for the role below.

TARGET ROLE: {job_title} at {company}

JOB DESCRIPTION:
{job_description[:2000]}

CANDIDATE'S ORIGINAL RESUME:
{resume_text[:2500]}

MANDATORY ATS KEYWORDS (you MUST include ALL of these naturally in the resume):
{kw_list}

RULES FOR 95%+ ATS SCORE:
1. Mirror the exact language from the JD β€” use the same phrases, not synonyms
2. Every bullet point MUST start with a strong action verb (Led, Built, Drove, Scaled, Launched, Reduced, Increased, Delivered)
3. Every bullet MUST include a quantified metric (%, numbers, $ impact, time saved, users impacted)
4. Professional summary must open with the exact job title from the JD and include 3+ keywords from the list
5. Core competencies must include ALL mandatory keywords above plus 6+ tools/frameworks from the JD
6. Include PM-specific terms: product roadmap, go-to-market, sprint, backlog, user story, A/B testing, funnel, retention
7. Do NOT add skills the candidate doesn't have β€” rephrase existing experience to match JD language

Return ONLY valid JSON (no markdown):
{{
  "professional_summary": "<4-5 sentences. Open with exact job title. Include 5+ keywords. Quantify impact.>",
  "core_competencies": ["skill1","skill2","skill3","skill4","skill5","skill6","skill7","skill8","skill9","skill10","skill11","skill12","skill13","skill14","skill15"],
  "experience_bullets": {{
    "role_name_1": ["β€’ Led X resulting in Y% improvement", "β€’ Built Z used by N users", "β€’ Drove A increasing B by C%"],
    "role_name_2": ["β€’ Launched X achieving Y", "β€’ Reduced X by N%"]
  }},
  "key_achievements": ["Achieved X resulting in Y", "Built Z growing metric by N%", "Led team of N to deliver X on time"],
  "cover_letter_intro": "<2 sentences tailored to {company} specifically>",
  "tailoring_notes": "<which keywords were added and where>"
}}"""

    def _empty_customization(self) -> dict:
        return {
            "professional_summary": "",
            "core_competencies": [],
            "experience_bullets": {},
            "key_achievements": [],
            "cover_letter_intro": "",
            "tailoring_notes": "Auto-customization failed.",
        }

    # ──────────────────────────────────────────────────────────
    # PROFILE EXTRACTION
    # ──────────────────────────────────────────────────────────
    def extract_profile_summary(self, resume_text: str) -> str:
        system = "You are a resume parser. Return ONLY valid JSON."
        user = f"""Parse this resume for job matching.

RESUME:
{resume_text[:3000]}

Return ONLY:
{{
  "name": "<name>",
  "current_role": "<role>",
  "total_experience_years": <N>,
  "summary": "<2 sentence summary>",
  "core_skills": ["s1","s2","s3","s4","s5","s6","s7","s8"],
  "domain_expertise": ["d1","d2"],
  "industries": ["i1","i2"],
  "education": "<degree + institution>",
  "certifications": ["c1"],
  "notable_achievements": ["a1","a2","a3"]
}}"""

        response = self._call(system, user, max_tokens=800)
        try:
            data = self._extract_json(response)
            return json.dumps(data, indent=2)
        except Exception:
            return resume_text[:1500]

    def extract_profile_summary_fast(self, cfg: dict, resume_text: str) -> str:
        """Like extract_profile_summary but uses a fast model (Kimi/Step) instead of GLM."""
        system = "You are a resume parser. Return ONLY valid JSON."
        user = f"""Parse this resume for job matching.

RESUME:
{resume_text[:3000]}

Return ONLY:
{{
  "name": "<name>",
  "current_role": "<role>",
  "total_experience_years": <N>,
  "summary": "<2 sentence summary>",
  "core_skills": ["s1","s2","s3","s4","s5","s6","s7","s8"],
  "domain_expertise": ["d1","d2"],
  "industries": ["i1","i2"],
  "education": "<degree + institution>",
  "certifications": ["c1"],
  "notable_achievements": ["a1","a2","a3"]
}}"""
        try:
            response = self._call_with_cfg(cfg, system, user, max_tokens=800)
            data = self._extract_json(response)
            return json.dumps(data, indent=2)
        except Exception:
            return self.extract_profile_summary(resume_text)  # fallback to GLM

    def build_compact_profile(self, profile_json: str) -> str:
        """Build a short ~200-char profile string for batch assessments."""
        try:
            d = json.loads(profile_json)
            skills = ", ".join(d.get("core_skills", [])[:8])
            return (
                f"{d.get('name','')} | {d.get('current_role','')} | "
                f"{d.get('total_experience_years','')} yrs exp | "
                f"Skills: {skills} | "
                f"Domain: {', '.join(d.get('domain_expertise',[])[:3])}"
            )
        except Exception:
            return profile_json[:300]