Spaces:
Sleeping
Sleeping
| import os | |
| import re | |
| from datetime import datetime | |
| from docx import Document | |
| from docx.shared import Pt, RGBColor, Inches | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| from docx.oxml.ns import qn | |
| from docx.oxml import OxmlElement | |
| from tqdm import tqdm | |
| from colorama import Fore, Style | |
| from .llm_client import LLMClient | |
| from .resume_parser import ResumeParser | |
| def _set_cell_bg(cell, hex_color: str): | |
| tc = cell._tc | |
| tcPr = tc.get_or_add_tcPr() | |
| shd = OxmlElement("w:shd") | |
| shd.set(qn("w:val"), "clear") | |
| shd.set(qn("w:color"), "auto") | |
| shd.set(qn("w:fill"), hex_color) | |
| tcPr.append(shd) | |
| def _read_docx_text(filepath: str) -> str: | |
| """ | |
| Read full DOCX text including table cells, in document order. | |
| python-docx's .paragraphs iterator skips table content, and appending | |
| tables at the end breaks section detection (CORE COMPETENCIES would have | |
| no content because the next line is PROFESSIONAL EXPERIENCE). Walking the | |
| body's XML children in order keeps the table immediately under its header. | |
| """ | |
| from docx import Document as _Doc | |
| from docx.oxml.ns import qn | |
| from docx.text.paragraph import Paragraph | |
| from docx.table import Table | |
| doc = _Doc(filepath) | |
| parts: list[str] = [] | |
| body = doc.element.body | |
| for child in body.iterchildren(): | |
| tag = child.tag | |
| if tag == qn("w:p"): | |
| text = Paragraph(child, doc).text | |
| if text: | |
| parts.append(text) | |
| elif tag == qn("w:tbl"): | |
| tbl = Table(child, doc) | |
| for row in tbl.rows: | |
| row_text = " ".join(c.text for c in row.cells if c.text) | |
| if row_text.strip(): | |
| parts.append(row_text) | |
| return "\n".join(parts) | |
| def _normalize_spaced_text(text: str) -> str: | |
| """Collapse PDF letter-spacing artifacts like 'E D U C A T I O N' β 'EDUCATION'. | |
| Detects runs of 3+ single uppercase letters separated by spaces and joins them. | |
| """ | |
| def _collapse(match): | |
| return re.sub(r"\s+", "", match.group(0)) | |
| # Match sequences like 'P R O F E S S I O N A L S U M M A R Y' | |
| return re.sub(r"(?:\b[A-Z]\s+){2,}[A-Z]\b", _collapse, text) | |
| def _extract_candidate_name(resume_text: str) -> str: | |
| """ | |
| Extract the candidate's name from the top of the resume. | |
| Tries in order: | |
| 1. ALL CAPS name on the first non-empty line (e.g. "SAITEJA TIRUNAGARI") | |
| 2. Title Case name on the first non-empty line | |
| 3. ALL CAPS name anywhere in the first 5 lines | |
| 4. Fallback: "Your Name" | |
| """ | |
| lines = [l.strip() for l in resume_text.splitlines() if l.strip()] | |
| if not lines: | |
| return "Your Name" | |
| # 1) First non-empty line as ALL CAPS name (2-5 words, no punctuation) | |
| first = lines[0] | |
| # Handle spaced-out ALL CAPS like "S A I T E J A T I R U N A G A R I" | |
| normalized_first = _normalize_spaced_text(first) | |
| m = re.match(r"^([A-Z][A-Z'\-\.]+(?:\s+[A-Z][A-Z'\-\.]+){1,4})\s*$", normalized_first) | |
| if m: | |
| name = m.group(1).strip() | |
| # Convert to Title Case for nicer display | |
| return " ".join(w.capitalize() for w in name.split()) | |
| # 2) Title Case on first line | |
| m = re.match(r"^([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,4})\s*$", first) | |
| if m: | |
| return m.group(1).strip() | |
| # 3) Scan first 5 lines for ALL CAPS name | |
| for ln in lines[:5]: | |
| ln_norm = _normalize_spaced_text(ln) | |
| m = re.match(r"^([A-Z][A-Z'\-\.]+(?:\s+[A-Z][A-Z'\-\.]+){1,4})\s*$", ln_norm) | |
| if m: | |
| name = m.group(1).strip() | |
| return " ".join(w.capitalize() for w in name.split()) | |
| return "Your Name" | |
| class ResumeCustomizer: | |
| def __init__(self, llm_client: LLMClient, resume_text: str, output_dir: str, | |
| fast_model_cfg: dict = None): | |
| self.llm = llm_client | |
| self.resume_text = resume_text | |
| self.fast_model_cfg = fast_model_cfg # used for both ATS keyword extraction AND resume customization | |
| # Organise resumes by date: data/output/resumes/YYYY-MM-DD/ | |
| date_str = datetime.now().strftime("%Y-%m-%d") | |
| self.output_dir = os.path.join(output_dir, date_str) | |
| os.makedirs(self.output_dir, exist_ok=True) | |
| def customize_for_jobs( | |
| self, | |
| assessed_jobs: list[dict], | |
| min_score_for_llm: int = 6, | |
| max_llm_resumes: int = 20, | |
| generate_all: bool = True, | |
| model_cfgs: list[dict] = None, | |
| progress_cb=None, | |
| ) -> list[dict]: | |
| """ | |
| Generate resumes for jobs: | |
| - LLM-tailored (high quality) for jobs scoring >= min_score_for_llm, | |
| generated IN PARALLEL across the model pool (one model per worker) | |
| - Template-based (instant) for all other PM-relevant jobs if generate_all=True | |
| model_cfgs: list of model config dicts; workers round-robin across them so | |
| each parallel resume hits a different API key (no rate limiting). | |
| progress_cb: optional callable(done, total, message) for live UI updates. | |
| """ | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| llm_eligible = [j for j in assessed_jobs if j.get("relevance_score", 0) >= min_score_for_llm][:max_llm_resumes] | |
| template_eligible = [j for j in assessed_jobs if j.get("relevance_score", 0) < min_score_for_llm and generate_all] | |
| print(f"\n{Fore.CYAN}Generating resumes:") | |
| print(f" LLM-tailored: {len(llm_eligible)} jobs (score >= {min_score_for_llm}) β PARALLEL") | |
| print(f" Template-only: {len(template_eligible)} jobs{Style.RESET_ALL}") | |
| cfg_pool = [c for c in (model_cfgs or []) if c and c.get("api_key")] | |
| # GUARDRAIL (Phase 5): resume tailoring quality varies a lot by model. | |
| # Restrict the tailoring pool to the "capable tier" (tailor=True) β | |
| # Kimi/Qwen-397b/DeepSeek-Pro/GPT-OSS β and exclude weaker models | |
| # (Step, Qwen-122b) that drop roles or write sparse bullets. The | |
| # deterministic floor still backfills, but starting from a stronger | |
| # model means more natural prose + higher first-pass coverage. | |
| tailor_pool = [c for c in cfg_pool if c.get("tailor")] | |
| if tailor_pool: | |
| cfg_pool = tailor_pool | |
| print(f"{Fore.CYAN} Tailoring models (capable tier): " | |
| f"{', '.join(c.get('name','?') for c in cfg_pool)}{Style.RESET_ALL}") | |
| if not cfg_pool and self.fast_model_cfg: | |
| cfg_pool = [self.fast_model_cfg] | |
| n_workers = min(6, max(1, len(cfg_pool))) if cfg_pool else 1 | |
| total = len(llm_eligible) | |
| done_count = [0] | |
| def _process_one(idx_job): | |
| idx, job = idx_job | |
| cfg = cfg_pool[idx % len(cfg_pool)] if cfg_pool else None | |
| co = job.get("company", "?")[:25] | |
| ttl = job.get("title", "?")[:30] | |
| try: | |
| path = self._generate_resume(job, use_llm=True, cfg=cfg) | |
| job["resume_path"] = path | |
| job["resume_generated"] = "LLM Tailored" | |
| from .ats_scorer import score_resume, score_before_after as _sba | |
| jd = job.get("description", "") | |
| assessed_kw = [k.strip() for k in job.get("ats_keywords","").split(",") if k.strip()] | |
| orig_result = score_resume(self.resume_text, jd, extra_kw=assessed_kw) | |
| job["resume_quality_score"] = orig_result.get("resume_quality", 0) | |
| if jd and path and os.path.exists(path): | |
| doc_text = _read_docx_text(path) | |
| b, a, imp = _sba(self.resume_text, doc_text, jd, extra_kw=assessed_kw) | |
| job["ats_score_before"] = b | |
| job["ats_score_after"] = a | |
| job["ats_improvement"] = imp | |
| msg = f"β {co} β ATS {b}% β {a}% (+{imp}pp)" | |
| else: | |
| from .ats_scorer import conservative_display_score as _cds0 | |
| _c = _cds0(orig_result["ats_score"]) | |
| job["ats_score_before"] = _c | |
| job["ats_score_after"] = _c | |
| job["ats_improvement"] = 0 | |
| msg = f"β {co} β saved, but no JD for ATS comparison" | |
| return job, msg | |
| except Exception as e: | |
| import traceback | |
| job["resume_path"] = "" | |
| job["resume_generated"] = f"Error: {e}" | |
| # Always score original resume so ATS Before shows in sheet | |
| try: | |
| from .ats_scorer import score_resume as _sr_fb, conservative_display_score as _cds1 | |
| jd = job.get("description", "") | |
| kw = [k.strip() for k in job.get("ats_keywords","").split(",") if k.strip()] | |
| r = _sr_fb(self.resume_text, jd, extra_kw=kw) | |
| _c = _cds1(r["ats_score"]) | |
| job["ats_score_before"] = _c | |
| job["ats_score_after"] = _c | |
| job["ats_improvement"] = 0 | |
| job["resume_quality_score"]= r.get("resume_quality", 0) | |
| except Exception: | |
| job["ats_score_before"] = 0 | |
| job["ats_score_after"] = 0 | |
| job["ats_improvement"] = 0 | |
| return job, f"β {co} β {ttl}: {e} | {traceback.format_exc().splitlines()[-1][:80]}" | |
| if llm_eligible: | |
| with tqdm(total=total, desc=f"LLM resumes ({n_workers} parallel)", colour="blue") as pbar: | |
| with ThreadPoolExecutor(max_workers=n_workers) as pool: | |
| futures = [pool.submit(_process_one, (i, j)) for i, j in enumerate(llm_eligible)] | |
| for fut in as_completed(futures): | |
| job_done, msg = fut.result() | |
| done_count[0] += 1 | |
| color = Fore.GREEN if msg.startswith("β") else (Fore.YELLOW if msg.startswith("β ") else Fore.RED) | |
| tqdm.write(f" {color}{msg}{Style.RESET_ALL}") | |
| if progress_cb: | |
| # Forward the completed job so the UI can show it | |
| # immediately (per-job incremental results). Falls | |
| # back to the old 3-arg signature for compatibility. | |
| try: | |
| progress_cb(done_count[0], total, msg, job_done) | |
| except TypeError: | |
| try: | |
| progress_cb(done_count[0], total, msg) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| pbar.update(1) | |
| # Template resumes for the rest | |
| with tqdm(total=len(template_eligible), desc="Template resumes", colour="cyan") as pbar: | |
| for job in template_eligible: | |
| try: | |
| path = self._generate_resume(job, use_llm=False) | |
| job["resume_path"] = path | |
| job["resume_generated"] = "Template" | |
| # ATS scoring β JD-based, template so before = after | |
| from .ats_scorer import score_resume as _sr, conservative_display_score as _cds2 | |
| jd = job.get("description", "") | |
| assessed_kw = [k.strip() for k in job.get("ats_keywords","").split(",") if k.strip()] | |
| # Score the GENERATED file (incl. injection), not the raw original | |
| try: | |
| _txt = _read_docx_text(path) if path and os.path.exists(path) else self.resume_text | |
| except Exception: | |
| _txt = self.resume_text | |
| result = _sr(_txt, jd, extra_kw=assessed_kw) | |
| _c = _cds2(result["ats_score"]) | |
| job["ats_score_before"] = _c | |
| job["ats_score_after"] = _c | |
| job["ats_improvement"] = 0 | |
| job["resume_quality_score"]= result.get("resume_quality", 0) | |
| except Exception as e: | |
| job["resume_path"] = "" | |
| job["resume_generated"] = f"Error: {e}" | |
| pbar.update(1) | |
| # ββ Batch DOCX β PDF (one Word session for all files β fast and stable) ββ | |
| if progress_cb: | |
| try: | |
| progress_cb(total, total, "Converting resumes to PDFβ¦") | |
| except Exception: | |
| pass | |
| try: | |
| from .pdf_writer import convert_folder | |
| pdf_map = convert_folder(self.output_dir) | |
| for job in assessed_jobs: | |
| p = job.get("resume_path", "") | |
| if p: | |
| job["resume_pdf_path"] = pdf_map.get(os.path.abspath(p), "") | |
| n_pdf = sum(1 for v in pdf_map.values() if v) | |
| print(f"{Fore.GREEN}β PDFs generated: {n_pdf}/{len(pdf_map)}{Style.RESET_ALL}") | |
| except Exception as e: | |
| print(f"{Fore.YELLOW}β PDF conversion failed: {e}{Style.RESET_ALL}") | |
| return assessed_jobs | |
| def _generate_resume(self, job: dict, use_llm: bool = True, cfg: dict = None) -> str: | |
| company = re.sub(r'[\\/*?:"<>|]', "", job.get("company", "Company")) | |
| title = re.sub(r'[\\/*?:"<>|]', "", job.get("title", "Role")) | |
| filename = f"{company}_{title}.docx"[:120] | |
| filepath = os.path.join(self.output_dir, filename) | |
| if not use_llm: | |
| return self._generate_template_resume(filepath, job) | |
| cfg = cfg or self.fast_model_cfg | |
| # ββ Phase 4 canonical flow: Resume model β LLM β Resume model β render ββ | |
| # Falls back to the older bullet-rewriter path on any failure. | |
| try: | |
| v4_path = self._generate_resume_v4(job, cfg, filepath) | |
| if v4_path: | |
| return v4_path | |
| except Exception as e: | |
| print(f"[v4 fallback] {company}: {e}") | |
| # ββ Legacy path (Phase 2/3) β kept as safety net ββ | |
| from .ats_scorer import score_resume as _score_resume, get_gap_report | |
| jd_text = job.get("description", "") | |
| # Parse contact info once | |
| parser = ResumeParser.__new__(ResumeParser) | |
| parser.pdf_path = "" | |
| contact = parser.get_contact_info(self.resume_text) | |
| # Keywords from assessment β used for ALL scoring so loop + final report agree | |
| assessed_kw = [k.strip() for k in job.get("ats_keywords", "").split(",") if k.strip()] | |
| # Baseline: what the ORIGINAL resume scores against this JD. | |
| # The tailored resume must never end up below this. | |
| try: | |
| baseline = _score_resume(self.resume_text, jd_text, extra_kw=assessed_kw)["ats_score"] | |
| except Exception: | |
| baseline = 0 | |
| # Build indexed bullets ONCE so the LLM can reference them by "role:idx". | |
| # This drives the v2 contract where the LLM rewrites specific bullets | |
| # rather than producing a generic highlights block. | |
| indexed_bullets = self._extract_bullets_indexed() | |
| # Iterative optimization loop: up to 3 attempts to reach 95% | |
| best_customization = None | |
| best_score = 0 | |
| for attempt in range(3): | |
| # Build gap-aware prompt on 2nd+ attempt | |
| extra_instruction = "" | |
| if attempt > 0 and best_customization: | |
| from docx import Document as _Doc | |
| try: | |
| doc_text = _read_docx_text(filepath) | |
| gap_report = get_gap_report(doc_text, jd_text) | |
| extra_instruction = ( | |
| f"\n\nIMPORTANT β Previous ATS score was {best_score}/100 (target: 95+).\n" | |
| f"Gaps:\n{gap_report}\n" | |
| f"Rewrite MORE bullets to weave in the missing JD keywords. " | |
| f"DO NOT add a 'Skills' or 'Competencies' section β keywords must live inside bullets and the summary." | |
| ) | |
| except Exception: | |
| pass | |
| # Retry attempts fall back to the primary fast model (most reliable JSON) | |
| attempt_cfg = cfg if attempt == 0 else (self.fast_model_cfg or cfg) | |
| if attempt_cfg: | |
| customization = self.llm.customize_resume_fast( | |
| attempt_cfg, | |
| resume_text=self.resume_text + extra_instruction, | |
| job_description=jd_text, | |
| job_title=job.get("title", ""), | |
| company=job.get("company", ""), | |
| assessment=job.get("_raw_assessment", {}), | |
| indexed_bullets=indexed_bullets, | |
| ) | |
| else: | |
| customization = self.llm.customize_resume( | |
| resume_text=self.resume_text + extra_instruction, | |
| job_description=jd_text, | |
| job_title=job.get("title", ""), | |
| company=job.get("company", ""), | |
| assessment=job.get("_raw_assessment", {}), | |
| indexed_bullets=indexed_bullets, | |
| ) | |
| # Empty/invalid customization β don't write a hollow resume, try again | |
| has_content = ( | |
| customization.get("professional_summary") | |
| or customization.get("rewritten_bullets") | |
| or customization.get("new_bullets") | |
| or customization.get("experience_bullets") # v1 backward-compat | |
| ) | |
| if not has_content: | |
| continue | |
| # Write DOCX | |
| self._write_docx(filepath, job, customization, contact) | |
| # Score with the SAME keywords used in the final before/after report | |
| try: | |
| from docx import Document as _Doc2 | |
| doc_text = _read_docx_text(filepath) | |
| result = _score_resume(doc_text, jd_text, extra_kw=assessed_kw) | |
| current_score = result["ats_score"] | |
| except Exception: | |
| current_score = 0 | |
| if current_score > best_score: | |
| best_score = current_score | |
| best_customization = customization | |
| if current_score >= 92: | |
| break # Target reached (relaxed from 95 β over-targeting was wasting LLM calls) | |
| # Make sure the file on disk is the BEST attempt, not just the last one | |
| if best_customization is not None: | |
| self._write_docx(filepath, job, best_customization, contact) | |
| # If still below 92, inject missing keywords directly | |
| if best_customization is not None and best_score < 92: | |
| self._inject_missing_keywords(filepath, jd_text, extra_kw=assessed_kw) | |
| try: | |
| from docx import Document as _Doc3 | |
| doc_text = _read_docx_text(filepath) | |
| best_score = _score_resume(doc_text, jd_text, extra_kw=assessed_kw)["ats_score"] | |
| except Exception: | |
| pass | |
| # GUARANTEE: tailored must beat the original. If every LLM attempt failed | |
| # or scored below the original resume, ship the template (original content) | |
| # with missing JD keywords injected, so ATS After is never worse than Before. | |
| if best_customization is None or best_score < baseline: | |
| path = self._generate_template_resume(filepath, job) | |
| if jd_text: | |
| self._inject_missing_keywords(path, jd_text, extra_kw=assessed_kw) | |
| # Postcondition: no dump footer, no Skills section | |
| try: | |
| self._assert_no_dump_footer(path) | |
| except AssertionError as e: | |
| # Log but don't crash β keep file for inspection | |
| print(f"[postcondition] {os.path.basename(path)}: {e}") | |
| return path | |
| # Postcondition on the LLM-tailored path too | |
| try: | |
| self._assert_no_dump_footer(filepath) | |
| except AssertionError as e: | |
| print(f"[postcondition] {os.path.basename(filepath)}: {e}") | |
| # ββ Diagnostic log: capture WHAT the LLM produced + WHY score is what | |
| # it is, so we can debug sub-90% production runs without re-running. | |
| try: | |
| self._log_tailoring_diagnostic( | |
| filepath=filepath, job=job, jd_text=jd_text, | |
| assessed_kw=assessed_kw, | |
| customization=best_customization or {}, | |
| final_score=best_score, baseline=baseline, | |
| ) | |
| except Exception: | |
| pass | |
| return filepath | |
| # Words/phrases that look like JD keywords but are actually company names, | |
| # generic prose, or marketing fluff β never inject these into a resume. | |
| _KEYWORD_BLOCKLIST = { | |
| # Company / brand names commonly found in JD "about us" sections | |
| "adani", "godrej", "yakult", "wipro", "physicswallah", "physics wallah", | |
| "asian paints", "bluelotus", "marsshot", "skullcandy", "vivo", "cosco", | |
| "aditya birla", "delhi transport", "transport corporation", | |
| # Generic prose / marketing terms | |
| "businesses", "businesses grow", "high revenues", "revenues", | |
| "messages", "working", "platform", "mission", "startup", "angel", | |
| "angel investors", "investors", "crores", "today", "enabling", | |
| "group", "delhi", "about", "corporation", "high", | |
| } | |
| # Vague buzzwords that look like skills but are flagged/penalised by real | |
| # checkers (Resume Worded's "Buzzwords" fix) and add no ATS value. We never | |
| # INJECT these β they are abstractions, not the concrete tools/methods/ | |
| # domains that count as keywords. (They may still appear in a JD; we simply | |
| # don't stuff them into the resume.) General-purpose, not JD-specific. | |
| # NOTE: real soft skills (communication, leadership, collaboration, | |
| # stakeholder management, ownership) are NOT buzzwords β real checkers | |
| # (Jobalytics) count them as keywords, so they are deliberately absent here. | |
| # This set is ONLY vague filler/marketing fluff that adds no ATS value and | |
| # gets flagged (Resume Worded "Buzzwords" fix). We never inject these. | |
| _BUZZWORDS = { | |
| "innovation", "innovative", "solutions", "solution", "tools", "tool", | |
| "synergy", "dynamic", "passionate", "motivated", "results-driven", | |
| "results driven", "detail-oriented", "detail oriented", "team player", | |
| "track record", "expertise", "strengths", "strength", "best practices", | |
| "value-add", "thought leadership", "self-starter", "go-getter", | |
| "fast-paced", "cutting-edge", "world-class", "robust", "seamless", | |
| "holistic", "leverage", "leveraging", "spearheaded", "passion", | |
| "excellence", "proven", "successful", "enterprise", "productivity", | |
| "authority", "generation", "organisation", "organization", "goals", | |
| "thing", "things", "tasks", "task", | |
| } | |
| # Allowlist patterns: only inject keywords that look like actual skills | |
| _SKILL_PATTERNS = [ | |
| # Tools / platforms | |
| r"\b(?:jira|figma|mixpanel|amplitude|metabase|tableau|looker|salesforce|" | |
| r"hubspot|webengage|clevertap|notion|confluence|asana|trello|linear|miro|" | |
| r"slack|airtable|productboard|hotjar|segment|ga4|google analytics|power\s*bi|" | |
| r"google ads|zoom|adobe|whatsapp business)\b", | |
| # Frameworks / methodologies | |
| r"\b(?:agile|scrum|kanban|lean|okrs?|design thinking|sprint(?:\s+planning)?|" | |
| r"backlog|story mapping|hypothesis testing|product-led growth|0\s*to\s*1|0β1|" | |
| r"product roadmap|product strategy|product vision|go-to-market|gtm|mvp|prd|prds|" | |
| r"product lifecycle|feature prioritization|roadmap|wireframes?|" | |
| r"acceptance criteria|release notes|user stor(?:y|ies))\b", | |
| # Technical | |
| r"\b(?:sql|python|api|apis|crm|automation|llm|llms|conversational ai|" | |
| r"machine learning|webhooks?|databases?|system architecture|integrations?|" | |
| r"workflows?|dashboards?|saas|b2b|b2c|martech|fintech|edtech|healthtech|ecommerce|" | |
| r"chatbot|chatbots|ocr|whatsapp(?:\s+business\s+api)?|campaign management|" | |
| r"engagement|messaging|billing|onboarding|activation|adoption|notifications?)\b", | |
| # PM-domain skills | |
| r"\b(?:a/b testing|user research|funnel optimization|" | |
| r"conversion(?:\s+rate)?(?:\s+optimization)?|retention|kpi|kpis|cross-functional|" | |
| r"stakeholder(?:\s+management)?|cohort analysis|user journey(?:\s+mapping)?|" | |
| r"ux(?:\s+research)?|customer empathy|customer success|customer insights|" | |
| r"smb|smbs|campaign|cs|sales|qa|" | |
| r"discovery|launch|prioritization|metrics|analytics|growth)\b", | |
| # Soft / leadership | |
| r"\b(?:ownership|leadership|communication|mentoring|collaboration|" | |
| r"strategic thinking|problem.?solving|data.?driven|agile/scrum)\b", | |
| ] | |
| def _is_actual_skill(self, keyword: str) -> bool: | |
| """Return True iff the keyword looks like a real skill/tool/methodology.""" | |
| kw = keyword.strip().lower() | |
| if not kw or len(kw) < 2: | |
| return False | |
| if kw in self._KEYWORD_BLOCKLIST: | |
| return False | |
| # Drop pure numbers / years-of-experience phrases | |
| if re.fullmatch(r"\d+\+?\s*years?", kw): | |
| return False | |
| # Must match one of the skill patterns | |
| for pat in self._SKILL_PATTERNS: | |
| if re.search(pat, kw, re.IGNORECASE): | |
| return True | |
| return False | |
| def _inject_missing_keywords(self, filepath: str, jd_text: str, extra_kw: list = None): | |
| """ | |
| Weave missing JD keywords into the resume β NEVER as a standalone | |
| section, NEVER as an "Additional relevant skills" footer. | |
| Strategy: find still-missing skill keywords and append them as a single | |
| natural sentence at the END of the Professional Summary paragraph. | |
| Example: "Recent work spans Jira, Figma, Mixpanel, Amplitude, and GA4." | |
| This keeps the resume looking professional (a sentence in the summary | |
| reads as candidate self-description, not a keyword dump) and gets the | |
| ATS keyword coverage we need. | |
| The function is a SAFETY NET. The LLM v2 contract should have woven | |
| most keywords into bullets already. This handles tools/skills the | |
| LLM missed without producing visible spam. | |
| """ | |
| from .ats_scorer import extract_jd_keywords, _kw_in_text | |
| from docx import Document as _Doc | |
| try: | |
| doc = _Doc(filepath) | |
| # Read all text (paragraphs + tables) to know what's already covered | |
| doc_text_parts: list[str] = [] | |
| for p in doc.paragraphs: | |
| if p.text: | |
| doc_text_parts.append(p.text) | |
| for t in doc.tables: | |
| for row in t.rows: | |
| for c in row.cells: | |
| if c.text: | |
| doc_text_parts.append(c.text) | |
| doc_text = "\n".join(doc_text_parts).lower() | |
| jd_keywords = extract_jd_keywords(jd_text) | |
| for kw in (extra_kw or []): | |
| if kw and kw.lower() not in jd_keywords: | |
| jd_keywords.append(kw.lower()) | |
| # Trust the JD extractor's earlier noise filter: if a keyword | |
| # survived `extract_jd_keywords` it's a real JD requirement. We | |
| # don't double-filter via _is_actual_skill (too narrow allowlist | |
| # was rejecting valid domain terms like FSD/MLOps/SIEM). | |
| # | |
| # We DO still drop terms that are pure stems (lemmatizer artifacts) | |
| # or single short tokens that wouldn't read naturally in prose. | |
| missing = [] | |
| for kw in jd_keywords: | |
| if _kw_in_text(kw, doc_text): | |
| continue | |
| kw_clean = kw.strip() | |
| # Skip lemmatizer artifacts ("integrat", "automat", "operat") | |
| if len(kw_clean) >= 5 and kw_clean.endswith(("at", "iz", "ic")): | |
| continue | |
| if len(kw_clean) < 2: | |
| continue | |
| # Drop vague buzzwords (penalised by real checkers) and known | |
| # company/prose blocklist terms β never stuff these. | |
| if kw_clean.lower() in self._BUZZWORDS or kw_clean.lower() in self._KEYWORD_BLOCKLIST: | |
| continue | |
| missing.append(kw_clean) | |
| # Dedup lemma-equivalents (Epic/Epics, PRD/PRDs, roadmap/product roadmap) | |
| missing = self._dedup_keywords_by_lemma(missing) | |
| if not missing: | |
| return | |
| # Prioritise the most valuable missing terms: known skills and | |
| # recurring JD terms first. | |
| from .ats_scorer import _is_professional_term as _isprof | |
| jd_low = jd_text.lower() | |
| missing.sort( | |
| key=lambda k: (_isprof(k.lower()), jd_low.count(k.lower())), | |
| reverse=True, | |
| ) | |
| # Split into "skills/methods" vs "domains" so each sentence reads | |
| # naturally instead of mixing tools and industries in one list. | |
| _DOMAIN_WORDS = { | |
| "lending", "credit", "insurance", "fraud", "banking", | |
| "logistics", "fintech", "edtech", "healthtech", "ecommerce", | |
| "e-commerce", "martech", "marketing", "payments", "b2b", "b2c", | |
| } | |
| skills = [self._format_skill_name(s) for s in missing if s.lower() not in _DOMAIN_WORDS] | |
| domains = [self._format_skill_name(s) for s in missing if s.lower() in _DOMAIN_WORDS] | |
| def _join(items): | |
| if len(items) == 1: | |
| return items[0] | |
| if len(items) == 2: | |
| return f"{items[0]} and {items[1]}" | |
| return f"{', '.join(items[:-1])}, and {items[-1]}" | |
| # SMART FILL (user directive): keep ALL meaningful missing keywords β | |
| # no cap. But a single long comma-list is (a) stripped by the scorer's | |
| # anti-spam guard and (b) penalised by real checkers. So we DISTRIBUTE | |
| # them across MULTIPLE short sentences, each its OWN paragraph and | |
| # each kept under the strip threshold (β€10 items β <15 separators). | |
| # Every paragraph is a separate line, so all of them survive scoring | |
| # and every keyword counts β while no single line looks like a dump. | |
| CHUNK = 10 | |
| openers = [ | |
| "Further strengths span {}.", | |
| "Additional hands-on experience includes {}.", | |
| "Also experienced with {}.", | |
| "Proficient across {}.", | |
| ] | |
| sentences: list[str] = [] | |
| for ci in range(0, len(skills), CHUNK): | |
| chunk = skills[ci:ci + CHUNK] | |
| opener = openers[(ci // CHUNK) % len(openers)] | |
| sentences.append(opener.format(_join(chunk))) | |
| for ci in range(0, len(domains), CHUNK): | |
| chunk = domains[ci:ci + CHUNK] | |
| sentences.append(f"Domain exposure includes {_join(chunk)}.") | |
| if not sentences: | |
| return | |
| # Find the Professional Summary content paragraph (anchor) | |
| summary_idx = None | |
| for i, p in enumerate(doc.paragraphs): | |
| if p.text.strip().upper().startswith("PROFESSIONAL SUMMARY"): | |
| for j in range(i + 1, min(i + 5, len(doc.paragraphs))): | |
| if doc.paragraphs[j].text.strip() and not doc.paragraphs[j].text.strip().upper().startswith("PROFESSIONAL"): | |
| summary_idx = j | |
| break | |
| break | |
| if summary_idx is None: | |
| for i, p in enumerate(doc.paragraphs): | |
| if p.text.strip() and not p.text.strip().upper().startswith(("SAITEJA", "PROFESSIONAL")): | |
| summary_idx = i | |
| break | |
| if summary_idx is None: | |
| return | |
| anchor = doc.paragraphs[summary_idx] | |
| # Each sentence becomes its OWN new paragraph right after the | |
| # summary, so each is a separate line kept under the strip threshold | |
| # (β€10 items). We do NOT append to the summary paragraph itself β | |
| # that paragraph already has commas, and combining could push the | |
| # line over 15 separators and get the whole line stripped. | |
| cursor = anchor | |
| for sent in sentences: | |
| cursor = self._insert_paragraph_after(cursor, sent, size=10.5) | |
| doc.save(filepath) | |
| except Exception: | |
| pass | |
| def _insert_paragraph_after(paragraph, text: str, size: float = 10.5): | |
| """Insert a new paragraph immediately after `paragraph` and return it.""" | |
| from docx.oxml import OxmlElement | |
| from docx.text.paragraph import Paragraph | |
| new_p = OxmlElement("w:p") | |
| paragraph._p.addnext(new_p) | |
| new_para = Paragraph(new_p, paragraph._parent) | |
| run = new_para.add_run(text) | |
| run.font.size = Pt(size) | |
| return new_para | |
| # Canonical capitalization for common skills/tools so the injected line | |
| # doesn't look like "Prds Saas Apis" β those should be "PRDs SaaS APIs". | |
| _SKILL_CASING = { | |
| "prds": "PRDs", "prd": "PRD", "saas": "SaaS", "apis": "APIs", "api": "API", | |
| "crm": "CRM", "ux": "UX", "ui": "UI", "kpi": "KPI", "kpis": "KPIs", | |
| "ga4": "GA4", "ai": "AI", "llm": "LLM", "llms": "LLMs", "ocr": "OCR", | |
| "qa": "QA", "cs": "CS", "smb": "SMB", "smbs": "SMBs", | |
| "siem": "SIEM", "soar": "SOAR", "xdr": "XDR", "edr": "EDR", | |
| "secops": "SecOps", "devops": "DevOps", "mlops": "MLOps", | |
| "ml": "ML", "nlp": "NLP", "plg": "PLG", "roi": "ROI", "sdk": "SDK", | |
| "sso": "SSO", "rbac": "RBAC", "cac": "CAC", "ltv": "LTV", "nps": "NPS", | |
| "b2b": "B2B", "b2c": "B2C", "okrs": "OKRs", "okr": "OKR", | |
| "edtech": "EdTech", "martech": "MarTech", "fintech": "FinTech", | |
| "healthtech": "HealthTech", "ecommerce": "eCommerce", "gtm": "GTM", | |
| "mvp": "MVP", "a/b testing": "A/B Testing", | |
| "whatsapp": "WhatsApp", "whatsapp business api": "WhatsApp Business API", | |
| "google analytics": "Google Analytics", "google ads": "Google Ads", | |
| "power bi": "Power BI", "ga": "Google Analytics", | |
| "conversational ai": "Conversational AI", | |
| } | |
| def _format_skill_name(self, kw: str) -> str: | |
| """Apply canonical capitalization or Title Case.""" | |
| k = kw.strip().lower() | |
| if k in self._SKILL_CASING: | |
| return self._SKILL_CASING[k] | |
| # Hyphenated / slashed terms: title-case each part | |
| if "/" in k: | |
| return "/".join(p.capitalize() for p in k.split("/")) | |
| # Title Case for multi-word | |
| return " ".join(w.capitalize() for w in k.split()) | |
| def _write_docx(self, filepath: str, job: dict, customization: dict, contact: dict): | |
| doc = Document() | |
| # Page margins | |
| for section in doc.sections: | |
| section.top_margin = Inches(0.7) | |
| section.bottom_margin = Inches(0.7) | |
| section.left_margin = Inches(0.8) | |
| section.right_margin = Inches(0.8) | |
| # Extract name from resume (handles ALL CAPS and Title Case) | |
| candidate_name = _extract_candidate_name(self.resume_text) | |
| # ββ HEADER ββ | |
| name_para = doc.add_paragraph() | |
| name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| run = name_para.add_run(candidate_name) | |
| run.bold = True | |
| run.font.size = Pt(20) | |
| run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E) | |
| contact_parts = [] | |
| if contact.get("phone"): | |
| contact_parts.append(contact["phone"]) | |
| if contact.get("email"): | |
| contact_parts.append(contact["email"]) | |
| if contact.get("linkedin"): | |
| contact_parts.append(contact["linkedin"]) | |
| if contact_parts: | |
| contact_para = doc.add_paragraph(" | ".join(contact_parts)) | |
| contact_para.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for run in contact_para.runs: | |
| run.font.size = Pt(10) | |
| run.font.color.rgb = RGBColor(0x44, 0x44, 0x44) | |
| # Horizontal rule | |
| doc.add_paragraph("β" * 85) | |
| # ββ PROFESSIONAL SUMMARY ββ | |
| self._add_section_header(doc, "PROFESSIONAL SUMMARY") | |
| summary = customization.get("professional_summary", "") | |
| if summary: | |
| p = doc.add_paragraph(summary) | |
| p.paragraph_format.space_after = Pt(8) | |
| for run in p.runs: | |
| run.font.size = Pt(10.5) | |
| # ββ PROFESSIONAL EXPERIENCE β bullets rewritten in place ββ | |
| # No CORE COMPETENCIES section (per user direction): all JD keywords | |
| # live inside the summary and the experience bullets, not a separate | |
| # skills block. Keywords are woven into the candidate's actual | |
| # achievements where semantically appropriate. | |
| self._add_section_header(doc, "PROFESSIONAL EXPERIENCE") | |
| # New v2 contract: customization["rewritten_bullets"] is a dict keyed | |
| # by "<role_idx>:<bullet_idx>" β rewritten text. customization["new_bullets"] | |
| # is a dict keyed by str(role_idx) β list of new bullet texts. | |
| rewritten = customization.get("rewritten_bullets", {}) or {} | |
| new_bullets_by_role = customization.get("new_bullets", {}) or {} | |
| # Backward-compat with old v1 contract: if the LLM returned the old | |
| # `experience_bullets: {role_key: [bullets]}` shape, treat each entry | |
| # as new_bullets for that role and let original bullets render verbatim. | |
| legacy_exp = customization.get("experience_bullets", {}) | |
| if isinstance(legacy_exp, dict) and not rewritten and not new_bullets_by_role: | |
| for k, bullets in legacy_exp.items(): | |
| if isinstance(bullets, list) and bullets: | |
| new_bullets_by_role.setdefault("0", []).extend( | |
| str(b).lstrip("β’-ββ*βͺβ ").strip() for b in bullets | |
| ) | |
| # Parse all roles from the original resume so structure is preserved. | |
| exp_sections = self._extract_experience_sections(self.resume_text) | |
| for exp_idx, exp in enumerate(exp_sections): | |
| role_para = doc.add_paragraph() | |
| run = role_para.add_run(exp.get("role", "")) | |
| run.bold = True | |
| run.font.size = Pt(11) | |
| run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E) | |
| role_para.paragraph_format.space_before = Pt(10) | |
| meta_para = doc.add_paragraph() | |
| meta_text = exp.get("company", "") | |
| if exp.get("dates"): | |
| meta_text += f" | {exp['dates']}" | |
| run = meta_para.add_run(meta_text) | |
| run.italic = True | |
| run.font.size = Pt(10) | |
| run.font.color.rgb = RGBColor(0x55, 0x55, 0x55) | |
| original_bullets = exp.get("bullets", []) | |
| real_bullet_counter = 0 # only real bullets (not Β§Β§HEADERΒ§Β§/Β§Β§METAΒ§Β§) get indices | |
| for bullet in original_bullets: | |
| b = str(bullet) | |
| if b.startswith("Β§Β§METAΒ§Β§"): | |
| meta_text = b.replace("Β§Β§METAΒ§Β§", "").strip() | |
| if meta_text: | |
| mp = doc.add_paragraph() | |
| run = mp.add_run(meta_text) | |
| run.italic = True | |
| run.font.size = Pt(9.5) | |
| run.font.color.rgb = RGBColor(0x55, 0x55, 0x55) | |
| elif b.startswith("Β§Β§HEADERΒ§Β§"): | |
| header_text = b.replace("Β§Β§HEADERΒ§Β§", "").strip() | |
| if header_text: | |
| hp = doc.add_paragraph() | |
| run = hp.add_run(header_text) | |
| run.bold = True | |
| run.font.size = Pt(10.5) | |
| run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E) | |
| hp.paragraph_format.space_before = Pt(6) | |
| else: | |
| # Look up rewritten version; fall back to original text. | |
| key = f"{exp_idx}:{real_bullet_counter}" | |
| text_to_render = rewritten.get(key) or b.lstrip("β’-ββ*βͺβ ").strip() | |
| # rewritten may be a dict {original, rewritten, keywords_added} | |
| if isinstance(text_to_render, dict): | |
| text_to_render = text_to_render.get("rewritten") or text_to_render.get("text") or b.lstrip("β’-ββ*βͺβ ").strip() | |
| p = doc.add_paragraph(style="List Bullet") | |
| run = p.add_run(str(text_to_render)) | |
| run.font.size = Pt(10.5) | |
| real_bullet_counter += 1 | |
| # Append any "new bullets" for this role at the end of its block. | |
| for nb in new_bullets_by_role.get(str(exp_idx), []) or []: | |
| text = nb["text"] if isinstance(nb, dict) else str(nb) | |
| p = doc.add_paragraph(style="List Bullet") | |
| run = p.add_run(text.lstrip("β’-ββ*βͺβ ").strip()) | |
| run.font.size = Pt(10.5) | |
| doc.add_paragraph() | |
| # ββ KEY ACHIEVEMENTS ββ | |
| achievements = customization.get("key_achievements", []) | |
| if achievements: | |
| self._add_section_header(doc, "KEY ACHIEVEMENTS") | |
| for ach in achievements: | |
| p = doc.add_paragraph(style="List Bullet") | |
| run = p.add_run(ach) | |
| run.font.size = Pt(10.5) | |
| # ββ EDUCATION (from original resume) ββ | |
| edu = self._extract_education(self.resume_text) | |
| if edu: | |
| self._add_section_header(doc, "EDUCATION") | |
| p = doc.add_paragraph(edu) | |
| for run in p.runs: | |
| run.font.size = Pt(10.5) | |
| doc.save(filepath) | |
| def _add_section_header(self, doc: Document, title: str): | |
| p = doc.add_paragraph() | |
| run = p.add_run(title) | |
| run.bold = True | |
| run.font.size = Pt(11) | |
| run.font.color.rgb = RGBColor(0x16, 0x48, 0x9E) | |
| p.paragraph_format.space_before = Pt(8) | |
| p.paragraph_format.space_after = Pt(4) | |
| # Bottom border on paragraph | |
| pPr = p._p.get_or_add_pPr() | |
| pBdr = OxmlElement("w:pBdr") | |
| bottom = OxmlElement("w:bottom") | |
| bottom.set(qn("w:val"), "single") | |
| bottom.set(qn("w:sz"), "6") | |
| bottom.set(qn("w:space"), "1") | |
| bottom.set(qn("w:color"), "1648BE") | |
| pBdr.append(bottom) | |
| pPr.append(pBdr) | |
| def _weave_keywords_into_bullets(self, resume, missing_keywords: list, | |
| jd_text: str) -> None: | |
| """ | |
| Surgically inject still-missing JD keywords into the most-relevant | |
| existing bullets in the Resume model. Modifies the resume in place. | |
| Strategy: | |
| 1. For each missing keyword, score each bullet by Jaccard token overlap | |
| with related context terms in the JD around that keyword | |
| 2. Pick the best-matching bullet and append a natural clause like | |
| " β leveraging KEYWORD" or " using KEYWORD" or "; KEYWORD-enabled" | |
| 3. Cap at 1 keyword per bullet to avoid runaway stuffing | |
| 4. Cap at 12 keywords total (excess goes to the summary closing line via | |
| the existing _inject_missing_keywords path) | |
| This is the "make every job score 88-95%" lever. Even when the LLM | |
| produces weak/sparse output, this post-step lifts coverage to 90%+ | |
| by adding keywords IN CONTEXT inside bullets, not as a footer dump. | |
| """ | |
| from .ats_scorer import _phrase_in_text, _lemma_tokens, _lemma | |
| if not missing_keywords: | |
| return | |
| # ββ Dedup lemma-equivalents: keep the longer/more-specific form ββ | |
| # Prevents "Epic" + "Epics", "PRD" + "PRDs", "roadmap" + "product | |
| # roadmap" all being woven separately. | |
| missing_keywords = self._dedup_keywords_by_lemma(missing_keywords) | |
| # Pre-compute lemma tokens for each bullet (cheap; ~50 bullets total) | |
| bullet_index: list[tuple] = [] # (role_idx, bullet_idx, text, lemma_set) | |
| for role_idx, role in enumerate(resume.roles): | |
| for bullet_idx, text in enumerate(role.bullets): | |
| lemmas = set(_lemma_tokens(text)) | |
| bullet_index.append((role_idx, bullet_idx, text, lemmas)) | |
| if not bullet_index: | |
| return | |
| # Build JD context for each keyword β words within 8 tokens of the keyword | |
| jd_low = jd_text.lower() | |
| jd_tokens = jd_low.split() | |
| kw_contexts: dict[str, set] = {} | |
| for kw in missing_keywords: | |
| kw_low = kw.lower() | |
| ctx_tokens: set = set() | |
| for i, tok in enumerate(jd_tokens): | |
| if kw_low in tok or any(part in tok for part in kw_low.split()): | |
| # Grab 8-token window around the match | |
| lo = max(0, i - 8) | |
| hi = min(len(jd_tokens), i + 9) | |
| for w in jd_tokens[lo:hi]: | |
| clean = re.sub(r"[^\w]", "", w) | |
| if len(clean) >= 4: | |
| ctx_tokens.add(clean) | |
| kw_contexts[kw] = ctx_tokens | |
| # Pair each keyword with the best-matching bullet. Two-pass strategy: | |
| # Pass 1: each bullet gets at most ONE keyword (best matches first) | |
| # Pass 2: stragglers can DOUBLE-UP on the most-relevant bullet | |
| from collections import defaultdict | |
| bullet_kw_count: dict = defaultdict(int) # (role_idx, bullet_idx) -> kw count | |
| keyword_to_bullet: dict[str, tuple] = {} | |
| leftover_keywords: list[str] = [] | |
| def _best_bullet_for(kw: str, max_per_bullet: int) -> tuple | None: | |
| """Pick the highest-overlap bullet that hasn't exceeded max_per_bullet.""" | |
| ctx = kw_contexts.get(kw, set()) | |
| best_score = -1.0 | |
| best_target = None | |
| for (role_idx, bullet_idx, text, lemmas) in bullet_index: | |
| key = (role_idx, bullet_idx) | |
| if bullet_kw_count[key] >= max_per_bullet: | |
| continue | |
| # Score: lemma overlap + small bonus for not-yet-used bullets | |
| overlap = 0.0 | |
| if ctx and lemmas: | |
| overlap = len(lemmas & ctx) / max(1, len(lemmas | ctx)) | |
| # Tie-break: bullets without any keyword yet beat those with one | |
| penalty = 0.001 * bullet_kw_count[key] | |
| score = overlap - penalty | |
| if score > best_score: | |
| best_score = score | |
| best_target = key | |
| return best_target | |
| # Only weave a keyword INTO A BULLET when it genuinely relates to that | |
| # bullet (high overlap). Everything else goes to ONE clean summary | |
| # sentence β far less spammy than tacking "β leveraging X" onto every | |
| # bullet. Cap bullet edits so the resume never reads as a template. | |
| WEAVE_THRESHOLD = 0.03 # min overlap to justify a bullet clause | |
| MAX_BULLET_EDITS = 28 # smart fill: weave as many relevant keywords as | |
| # possible IN CONTEXT (the rest go to summary) | |
| def _overlap(kw: str, key: tuple) -> float: | |
| ctx = kw_contexts.get(kw, set()) | |
| for (ri, bi, text, lemmas) in bullet_index: | |
| if (ri, bi) == key and ctx and lemmas: | |
| return len(lemmas & ctx) / max(1, len(lemmas | ctx)) | |
| return 0.0 | |
| still_left: list[str] = [] | |
| bullet_edits = 0 | |
| for kw in missing_keywords: | |
| if bullet_edits >= MAX_BULLET_EDITS: | |
| still_left.append(kw) | |
| continue | |
| target = _best_bullet_for(kw, max_per_bullet=1) | |
| if target and _overlap(kw, target) >= WEAVE_THRESHOLD: | |
| keyword_to_bullet[kw] = target | |
| bullet_kw_count[target] += 1 | |
| bullet_edits += 1 | |
| else: | |
| # Low relevance β summary sentence (reads cleaner than a forced | |
| # bullet clause) | |
| still_left.append(kw) | |
| # Apply the few high-relevance bullet edits in document order | |
| edits_by_bullet: dict = defaultdict(list) | |
| for kw, target in keyword_to_bullet.items(): | |
| edits_by_bullet[target].append(kw) | |
| for (role_idx, bullet_idx), kws in edits_by_bullet.items(): | |
| original = resume.roles[role_idx].bullets[bullet_idx] | |
| modified = original | |
| for kw in kws: | |
| modified = self._weave_clause(modified, kw) | |
| resume.roles[role_idx].bullets[bullet_idx] = modified | |
| # Everything else β ONE clean summary sentence (handled by injector) | |
| self._pending_summary_inject = still_left | |
| def _weave_clause(bullet_text: str, keyword: str) -> str: | |
| """ | |
| Append a natural-language clause that mentions the keyword. | |
| Pattern choice depends on bullet's existing structure to keep the | |
| result readable. The keyword is rendered with canonical casing so | |
| "PRDs" reads correctly, not "Prds". | |
| """ | |
| # Canonical casing | |
| casing = { | |
| "prds": "PRDs", "prd": "PRD", "saas": "SaaS", "apis": "APIs", "api": "API", | |
| "crm": "CRM", "ux": "UX", "ui": "UI", "kpi": "KPI", "kpis": "KPIs", | |
| "ga4": "GA4", "ai": "AI", "llm": "LLM", "llms": "LLMs", "ocr": "OCR", | |
| "qa": "QA", "cs": "CS", "smb": "SMB", "smbs": "SMBs", | |
| "b2b": "B2B", "b2c": "B2C", "okrs": "OKRs", "okr": "OKR", | |
| "edtech": "EdTech", "martech": "MarTech", "fintech": "FinTech", | |
| "healthtech": "HealthTech", "ecommerce": "eCommerce", "gtm": "GTM", | |
| "mvp": "MVP", "siem": "SIEM", "soar": "SOAR", "xdr": "XDR", | |
| "fsd": "FSD", "uat": "UAT", "mlops": "MLOps", | |
| "whatsapp": "WhatsApp", "whatsapp business api": "WhatsApp Business API", | |
| "google analytics": "Google Analytics", "google ads": "Google Ads", | |
| "power bi": "Power BI", "ga": "Google Analytics", | |
| "conversational ai": "Conversational AI", | |
| } | |
| # Natural multi-word forms for single-word skills so the clause reads | |
| # like prose, not a tag ("roadmap" β "roadmap planning"). | |
| natural = { | |
| "roadmap": "roadmap planning", "backlog": "backlog management", | |
| "scrum": "agile/scrum delivery", "agile": "agile delivery", | |
| "sprint": "sprint planning", "okr": "OKR tracking", "okrs": "OKR tracking", | |
| "analytics": "product analytics", "metrics": "metrics definition", | |
| "epics": "epics and user stories", "epic": "epics", | |
| "b2c": "B2C consumer products", "b2b": "B2B products", | |
| "cloud": "cloud platforms", "microservices": "microservices architecture", | |
| "jira": "Jira", "confluence": "Confluence", "aha": "Aha", | |
| "lending": "the lending domain", "credit": "the credit domain", | |
| "insurance": "the insurance domain", "fraud": "fraud detection", | |
| "ceremonies": "agile ceremonies", "iteration": "iterative delivery", | |
| "personalization": "personalization", "engagement": "engagement", | |
| "growth": "growth initiatives", "logistics": "the logistics domain", | |
| "marketing": "marketing technology", | |
| } | |
| kw_low = keyword.lower() | |
| if kw_low in natural: | |
| kw_disp = natural[kw_low] | |
| else: | |
| kw_disp = casing.get(kw_low, " ".join(w.capitalize() for w in keyword.split())) | |
| text = bullet_text.rstrip(" .;,") | |
| # Integrate as a natural trailing clause. Two grammatical forms keep it | |
| # from looking templated, chosen deterministically. | |
| options = [ | |
| f"{text}, applying {kw_disp}.", | |
| f"{text} through {kw_disp}.", | |
| ] | |
| idx = (sum(ord(c) for c in (bullet_text + keyword))) % len(options) | |
| return options[idx] | |
| def _dedup_keywords_by_lemma(keywords: list) -> list: | |
| """ | |
| Collapse lemma-equivalent keywords, keeping the longer/more-specific | |
| form. E.g. ["roadmap", "product roadmap", "epic", "epics"] β | |
| ["product roadmap", "epics"]. | |
| """ | |
| from .ats_scorer import _lemma | |
| def _key(kw: str) -> str: | |
| # Lemma of each word, joined β so "epic"/"epics" and | |
| # "roadmap"/"roadmaps" collapse | |
| return " ".join(_lemma(w) for w in kw.lower().split()) | |
| # Group by lemma-key; within each group keep the longest surface form | |
| best_by_key: dict = {} | |
| order: list = [] | |
| for kw in keywords: | |
| k = _key(kw) | |
| # Also collapse single-word into a multi-word phrase that contains it | |
| if k not in best_by_key: | |
| best_by_key[k] = kw | |
| order.append(k) | |
| elif len(kw) > len(best_by_key[k]): | |
| best_by_key[k] = kw | |
| result = [best_by_key[k] for k in order] | |
| # Second pass: drop a single-word keyword if a multi-word keyword | |
| # already contains it as a token (roadmap β product roadmap; | |
| # agile β agile/scrum; scrum β agile/scrum) | |
| expanded = [re.split(r"[\s/]+", k.lower()) for k in result] | |
| multiword_tokens = set() | |
| for parts in expanded: | |
| if len(parts) > 1: | |
| multiword_tokens.update(parts) | |
| final = [] | |
| for kw in result: | |
| parts = re.split(r"[\s/]+", kw.lower()) | |
| if len(parts) == 1 and parts[0] in multiword_tokens: | |
| continue # subsumed by a phrase (agile, scrum, roadmap, ...) | |
| final.append(kw) | |
| return final | |
| def _generate_resume_v4(self, job: dict, cfg: dict, filepath: str) -> str | None: | |
| """ | |
| Phase 4 canonical flow: | |
| 1. Parse the original resume PDF into a Resume model (cached) | |
| 2. Call LLM v4 contract: Resume + JD β tailored Resume | |
| 3. Render via canonical renderer (single locked visual format) | |
| 4. Score; if below threshold, do ONE more LLM pass with feedback | |
| 5. Always run keyword-injection safety net at the end | |
| 6. Return filepath OR None to fall back to legacy path | |
| The renderer writes one canonical format for ALL tailored resumes β | |
| no per-job format drift, no orphan lines, no sub-sections. | |
| """ | |
| from .resume_parser_v2 import parse_resume_pdf_cached | |
| from .resume_renderer import render_resume_docx | |
| from .resume_model import Resume | |
| from .ats_scorer import score_resume as _score_resume | |
| jd_text = job.get("description", "") or "" | |
| assessed_kw = [k.strip() for k in job.get("ats_keywords", "").split(",") if k.strip()] | |
| # 1. Load (and cache) the parsed canonical resume | |
| pdf_path = os.path.join("data", "resume", "resume.pdf") | |
| if not os.path.exists(pdf_path): | |
| return None # No source PDF β can't use v4 path | |
| base_resume = parse_resume_pdf_cached(pdf_path) | |
| # 2. LLM tailor β pass the dict, get back a tailored dict | |
| if not cfg: | |
| return None # Need a model cfg for v4 | |
| tailored_dict = self.llm.tailor_resume_v4( | |
| cfg=cfg, | |
| resume_dict=base_resume.to_dict(), | |
| jd_text=jd_text, | |
| job_title=job.get("title", ""), | |
| company=job.get("company", ""), | |
| assessment=job.get("_raw_assessment", {}), | |
| ) | |
| # Defensive: ensure we got SOMETHING usable | |
| try: | |
| tailored = Resume.from_dict(tailored_dict) | |
| except Exception: | |
| return None | |
| if not tailored.summary or not tailored.roles: | |
| return None | |
| # ββ Aggressive backfill: defend against weak LLM outputs βββββββββββ | |
| # In production, smaller LLMs (Step/Qwen variants) often: | |
| # 1. Drop older roles (BYJU's, ML Edutech) to save tokens | |
| # 2. Skip the recruiter pitch in summary | |
| # 3. Write only 2-3 bullets per role | |
| # All of which crater the ATS score. Defend deterministically: | |
| # 1. Preserve identity fields | |
| if not tailored.education: | |
| tailored.education = list(base_resume.education) | |
| if not tailored.name: | |
| tailored.name = base_resume.name | |
| if not tailored.contact.email and not tailored.contact.phone: | |
| tailored.contact = base_resume.contact | |
| # 2. Restore dropped roles. If the LLM returned fewer roles than the | |
| # base resume has, append the missing ones with original bullets. | |
| # Match by role title (case-insensitive substring); if no match, | |
| # treat each missing role as a fresh append. | |
| if len(tailored.roles) < len(base_resume.roles): | |
| tailored_titles = {r.title.lower().strip() for r in tailored.roles} | |
| for base_role in base_resume.roles: | |
| base_title_key = base_role.title.lower().strip() | |
| # Check if this role is already in tailored (substring match | |
| # handles "Internal Product Manager" vs "Product Manager") | |
| already_present = any( | |
| base_title_key in t or t in base_title_key | |
| for t in tailored_titles | |
| ) | |
| if not already_present: | |
| tailored.roles.append(Resume.from_dict({ | |
| "name": "", | |
| "roles": [{ | |
| "title": base_role.title, | |
| "company": base_role.company, | |
| "location": base_role.location, | |
| "dates": base_role.dates, | |
| "bullets": list(base_role.bullets[:5]), # cap at 5 | |
| }], | |
| }).roles[0]) | |
| # 3. Enforce minimum bullets per role. If LLM returned <3 bullets for | |
| # any role, supplement from the matching base role's bullets. | |
| for tailored_role in tailored.roles: | |
| if len(tailored_role.bullets) >= 4: | |
| continue | |
| # Find matching base role | |
| tk = tailored_role.title.lower().strip() | |
| base_match = None | |
| for br in base_resume.roles: | |
| bk = br.title.lower().strip() | |
| if tk in bk or bk in tk: | |
| base_match = br | |
| break | |
| if not base_match: | |
| continue | |
| # Add base bullets not already in tailored (dedupe by first 60 chars) | |
| existing_starts = {b[:60].lower() for b in tailored_role.bullets} | |
| for bb in base_match.bullets: | |
| if bb[:60].lower() in existing_starts: | |
| continue | |
| tailored_role.bullets.append(bb) | |
| if len(tailored_role.bullets) >= 5: | |
| break | |
| # 4. Enforce recruiter pitch in summary. If LLM didn't open with the | |
| # canonical pattern, deterministically prepend it. | |
| summary_lower = (tailored.summary or "").lower()[:80] | |
| has_pitch = any(p in summary_lower for p in [ | |
| "strong-fit candidate", "strong fit candidate", | |
| "well-suited candidate", "perfect fit", "ideal candidate", | |
| ]) | |
| if not has_pitch: | |
| company = job.get("company", "this role") | |
| title = job.get("title", "this role") | |
| pitch = ( | |
| f"Strong-fit candidate for {title} at {company}: 5+ years of " | |
| f"PM experience directly applicable to this role. " | |
| ) | |
| tailored.summary = pitch + (tailored.summary or "") | |
| # 2b. Pre-render scoring + aggressive keyword weaving into bullets. | |
| # Keyword filter: only inject keywords that pass `_is_actual_skill` | |
| # (allowlist of real PM tools/methodologies/domain terms). This | |
| # prevents non-skills (Dublin/FTSE/Director/Description/etc.) from | |
| # being woven into bullets as "skills". | |
| from .ats_scorer import ( | |
| extract_jd_keywords as _ext_kw, _kw_in_text as _kw_check, | |
| ) | |
| try: | |
| jd_kw = _ext_kw(jd_text) | |
| for kw in assessed_kw or []: | |
| if kw and kw.lower() not in jd_kw: | |
| jd_kw.append(kw.lower()) | |
| flat = tailored.to_flat_text().lower() | |
| missing = [k for k in jd_kw if not _kw_check(k, flat)] | |
| if missing: | |
| # Smart fill (user directive): weave EVERY meaningful JD keyword | |
| # into bullets where relevant β not just a narrow allowlist. | |
| # These already passed extract_jd_keywords' meaningful filter | |
| # (real nouns/skills, no prose/locations/company names). We only | |
| # additionally drop lemmatizer artifacts and vague BUZZWORDS that | |
| # real checkers penalise. The blocklist still removes known | |
| # company/prose terms. | |
| missing = [ | |
| k for k in missing | |
| if len(k) >= 3 | |
| and not (len(k) >= 5 and k.endswith(("at", "iz", "ic"))) | |
| and k.lower() not in self._BUZZWORDS | |
| and k.lower() not in self._KEYWORD_BLOCKLIST | |
| ] | |
| if missing: | |
| self._weave_keywords_into_bullets(tailored, missing, jd_text) | |
| # ββ Populate the categorized SKILLS section (industry standard) ββ | |
| # The skills section is the #1 ATS keyword vehicle: real checkers | |
| # (Jobalytics/Resume Worded) parse it and count hard skills. We list | |
| # the JD's REAL skills (taxonomy/vocab terms only β no prose nouns), | |
| # prioritised by known-skill then JD frequency, capped to a credible | |
| # ~26 (best practice = quality, each keyword 1-3x, not 100). | |
| from .ats_scorer import _is_professional_term as _isprof, _is_taxonomy_skill as _istax | |
| jd_low = jd_text.lower() | |
| skill_pool = [ | |
| k for k in jd_kw | |
| if k.lower() not in self._BUZZWORDS | |
| and k.lower() not in self._KEYWORD_BLOCKLIST | |
| and len(k) >= 3 | |
| ] | |
| skill_pool = self._dedup_keywords_by_lemma(skill_pool) | |
| skill_pool.sort( | |
| key=lambda k: (_istax(k.lower()), jd_low.count(k.lower())), | |
| reverse=True, | |
| ) | |
| tailored.skills = skill_pool[:26] | |
| except Exception as e: | |
| print(f"[weave] {e}") | |
| self._pending_summary_inject = [] | |
| # 3. Render to DOCX (after weaving + skills) β Skills section included | |
| render_resume_docx(tailored, filepath) | |
| # 4. Score. Keyword coverage now comes from the categorized Skills | |
| # section + contextual bullet weaving (industry standard) β NOT from | |
| # appending vague-noun sentences to the summary, which real checkers | |
| # penalise as stuffing. The old summary injection is retired. | |
| score = _score_resume(_read_docx_text(filepath), jd_text, extra_kw=assessed_kw)["ats_score"] | |
| # 5. Postcondition check | |
| try: | |
| self._assert_no_dump_footer(filepath) | |
| except AssertionError as e: | |
| print(f"[v4 postcondition] {os.path.basename(filepath)}: {e}") | |
| # 6. Diagnostic log β capture raw LLM output for debugging | |
| try: | |
| self._log_tailoring_diagnostic( | |
| filepath=filepath, job=job, jd_text=jd_text, | |
| assessed_kw=assessed_kw, customization=tailored_dict, | |
| final_score=score, baseline=0, | |
| v4_path_taken=True, | |
| v4_roles_returned=len(tailored_dict.get("roles", [])) if isinstance(tailored_dict, dict) else 0, | |
| v4_total_bullets=sum( | |
| len(r.get("bullets") or []) | |
| for r in (tailored_dict.get("roles") or []) | |
| if isinstance(r, dict) | |
| ) if isinstance(tailored_dict, dict) else 0, | |
| ) | |
| except Exception: | |
| pass | |
| return filepath | |
| def _log_tailoring_diagnostic(self, filepath: str, job: dict, jd_text: str, | |
| assessed_kw: list, customization: dict, | |
| final_score: int, baseline: int, | |
| v4_path_taken: bool = False, | |
| v4_roles_returned: int = 0, | |
| v4_total_bullets: int = 0) -> None: | |
| """ | |
| Append one JSONL record per tailoring run so we can debug why specific | |
| jobs land below the 90% target without re-running the LLM. The log | |
| captures: which keywords the JD wanted, which the LLM covered, which | |
| the resume actually contains after rendering, and the raw LLM output. | |
| File: data/logs/tailoring_YYYY-MM-DD.jsonl | |
| """ | |
| import json | |
| from datetime import datetime | |
| from .ats_scorer import score_resume as _score, extract_jd_keywords, _kw_in_text | |
| os.makedirs("data/logs", exist_ok=True) | |
| log_path = f"data/logs/tailoring_{datetime.now().strftime('%Y-%m-%d')}.jsonl" | |
| # What the JD asked for vs what landed in the doc | |
| try: | |
| jd_kw = extract_jd_keywords(jd_text) | |
| doc_text = _read_docx_text(filepath).lower() | |
| matched = [k for k in jd_kw if _kw_in_text(k, doc_text)] | |
| missing = [k for k in jd_kw if not _kw_in_text(k, doc_text)] | |
| except Exception: | |
| jd_kw, matched, missing = [], [], [] | |
| # Did the LLM produce the expected v2 schema? | |
| schema_diagnosis = { | |
| "has_summary": bool(customization.get("professional_summary")), | |
| "summary_len": len(customization.get("professional_summary", "") or ""), | |
| "has_rewritten_bullets": bool(customization.get("rewritten_bullets")), | |
| "rewritten_bullets_count": len(customization.get("rewritten_bullets", {}) or {}), | |
| "has_new_bullets": bool(customization.get("new_bullets")), | |
| "new_bullets_count": sum( | |
| len(v) if isinstance(v, list) else 0 | |
| for v in (customization.get("new_bullets", {}) or {}).values() | |
| ), | |
| "has_v1_experience_bullets": bool(customization.get("experience_bullets")), | |
| "has_v1_core_competencies": bool(customization.get("core_competencies")), | |
| } | |
| # Does the summary (in either v2 or v4 shape) open with a recruiter pitch? | |
| summary_text = ( | |
| customization.get("summary") # v4 key | |
| or customization.get("professional_summary") # v2 key | |
| or "" | |
| ).lower() | |
| has_pitch = any(p in summary_text[:120] for p in [ | |
| "strong-fit candidate", "strong fit candidate", "well-suited candidate", | |
| "perfect fit", "ideal candidate", | |
| ]) | |
| record = { | |
| "ts": datetime.now().isoformat(timespec="seconds"), | |
| "company": job.get("company", ""), | |
| "title": job.get("title", ""), | |
| "filepath": os.path.basename(filepath), | |
| "score": {"baseline": baseline, "final": final_score}, | |
| "keyword_coverage": { | |
| "jd_total": len(jd_kw), | |
| "matched": len(matched), | |
| "missing": missing[:20], | |
| "match_pct": int(100 * len(matched) / max(1, len(jd_kw))), | |
| }, | |
| "v4": { | |
| "path_taken": v4_path_taken, | |
| "roles_returned": v4_roles_returned, | |
| "total_bullets": v4_total_bullets, | |
| }, | |
| "llm_schema": schema_diagnosis, | |
| "has_recruiter_pitch": has_pitch, | |
| "summary_first_80": summary_text[:80], | |
| "assessed_kw_count": len(assessed_kw or []), | |
| } | |
| with open(log_path, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(record, ensure_ascii=False) + "\n") | |
| def _extract_bullets_indexed(self) -> list[tuple]: | |
| """ | |
| Return a flat list of (role_idx, bullet_idx, role_name, bullet_text) | |
| tuples for every real bullet in the candidate's resume. Sub-section | |
| headers (Β§Β§HEADERΒ§Β§) and meta lines (Β§Β§METAΒ§Β§) are NOT indexed β | |
| they're structural decoration, not rewritable bullets. | |
| The LLM uses these indices in its `rewritten_bullets` response keys. | |
| """ | |
| out: list[tuple] = [] | |
| for role_idx, exp in enumerate(self._extract_experience_sections(self.resume_text)): | |
| role_name = exp.get("role", "") | |
| bullet_idx = 0 | |
| for b in exp.get("bullets", []): | |
| s = str(b) | |
| if s.startswith(("Β§Β§HEADERΒ§Β§", "Β§Β§METAΒ§Β§")): | |
| continue | |
| out.append((role_idx, bullet_idx, role_name, s.lstrip("β’-ββ*βͺβ ").strip())) | |
| bullet_idx += 1 | |
| return out | |
| def _assert_no_dump_footer(filepath: str) -> None: | |
| """ | |
| Postcondition: the generated resume must NOT contain a keyword-DUMP. | |
| A clean, categorized SKILLS section is now ALLOWED (industry standard β | |
| it's the #1 ATS keyword vehicle). What stays banned is a raw dump: the | |
| legacy "Additional relevant skills" footer, or any single line with 15+ | |
| separators (the keyword-stuffing pattern real checkers penalise). | |
| """ | |
| from docx import Document as _Doc | |
| doc = _Doc(filepath) | |
| for p in doc.paragraphs: | |
| text = (p.text or "").strip() | |
| if not text: | |
| continue | |
| if text.lower().startswith("additional relevant skills"): | |
| raise AssertionError( | |
| f"Dump footer detected: '{text[:80]}'. " | |
| f"Keywords must be woven into bullets/skills, not appended as a footer." | |
| ) | |
| # Raw dump heuristic: one line with 15+ separators (commas/pipes/ | |
| # bullets). The categorized SKILLS section is safe β each line is | |
| # 'Category: a, b, c' with β€12 items (<15 separators). | |
| sep = text.count(",") + text.count("|") + text.count("β’") | |
| if sep >= 15: | |
| raise AssertionError( | |
| f"Keyword dump detected ({sep} separators): '{text[:80]}β¦'. " | |
| f"Distribute keywords across categorized lines, not one dump." | |
| ) | |
| def _extract_experience_sections(self, text: str) -> list[dict]: | |
| """ | |
| Parse PROFESSIONAL EXPERIENCE into individual roles. | |
| Strategy: locate every date-range in the experience text, split the | |
| text at each date-range position into role-blocks, then within each | |
| block separate the role header from its bullets and sub-sections. | |
| Date ranges may span line breaks ("Dec\\n2022") so we operate on the | |
| full text blob rather than line-by-line. | |
| Each role gets: | |
| - role: job title | |
| - company: company / location | |
| - dates: explicit date range (e.g. "Jan 2023 β Present") | |
| - bullets: ALL bullets under that role. Sub-section headers (lines | |
| that don't start with a bullet character) are prefixed | |
| with Β§Β§HEADERΒ§Β§ so the DOCX writer can render them bold. | |
| """ | |
| sections: list[dict] = [] | |
| text_norm = _normalize_spaced_text(text) | |
| # Locate experience section. Section-header lookahead requires the | |
| # next header to be in ALL CAPS so mid-prose words like | |
| # "certifications;" or "projects," can't end the match early. | |
| exp_match = re.search( | |
| r"(?:PROFESSIONAL\s+|WORK\s+)?EXPERIENCE[S]?\s*\n(.*?)" | |
| r"(?:\n(?:KEY\s+METRICS|KEY\s+ACHIEVEMENTS|CORE\s+COMPETENCIES|" | |
| r"TECHNICAL\s+SKILLS|SKILLS\s*&|SKILLS\s*\n|EDUCATION|" | |
| r"CERTIFICATIONS\s*\n|CERTIFICATIONS\s*&|PROJECTS\s*\n|PROJECTS\s*&|" | |
| r"AWARDS|LANGUAGES|REFERENCES)|\Z)", | |
| text_norm, re.DOTALL, | |
| ) | |
| if not exp_match: | |
| return sections | |
| exp_text = exp_match.group(1).strip() | |
| # Date-range pattern (allows whitespace including \n within the range) | |
| date_pattern = re.compile( | |
| r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4}\s*[-ββto]+\s*" | |
| r"(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s*\n?\s*\d{4}|Present|Current|Now)", | |
| re.IGNORECASE, | |
| ) | |
| # Find all date-range positions in the experience blob | |
| date_matches = list(date_pattern.finditer(exp_text)) | |
| if not date_matches: | |
| return sections | |
| # Build role blocks: each block runs from the start of one role's | |
| # header line to the start of the next role's header line. | |
| # The "header line" is the line containing the date β we find its | |
| # start by scanning back to the previous newline. | |
| block_starts: list[int] = [] | |
| for dm in date_matches: | |
| line_start = exp_text.rfind("\n", 0, dm.start()) + 1 | |
| block_starts.append(line_start) | |
| block_starts.append(len(exp_text)) # sentinel for the last block | |
| # Partial-date pattern to strip ANY month-year fragments from header | |
| # lines. Handles all of: | |
| # "Jan 2023 β Present" | |
| # "Oct 2021 β Dec 2022" | |
| # "Oct 2021 β Dec" β year on next line (wrap) | |
| # "Oct 2021" | |
| # "Oct" β bare month (rare but possible) | |
| # Bug C fix. | |
| partial_date_re = re.compile( | |
| r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*" | |
| r"(?:\s+\d{2,4})?" | |
| r"(?:\s*[-ββto]+\s*" | |
| r"(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*" | |
| r"(?:\s+\d{2,4})?" | |
| r"|\d{2,4}|Present|Current|Now)" | |
| r")?", | |
| re.IGNORECASE, | |
| ) | |
| for i in range(len(date_matches)): | |
| dm = date_matches[i] | |
| block = exp_text[block_starts[i]:block_starts[i + 1]] | |
| # Collapse any internal whitespace (handles "Oct 2021 β Dec\n2022") | |
| dates = re.sub(r"\s+", " ", dm.group()).strip() | |
| # Header line is the first line of the block (the one containing date) | |
| header_line_end = block.find("\n") | |
| if header_line_end == -1: | |
| header_line = block | |
| body = "" | |
| else: | |
| header_line = block[:header_line_end] | |
| body = block[header_line_end + 1:] | |
| # Bug C: strip ANY month-year fragments from the header line so a | |
| # wrapped date (header has "Oct 2021 β Dec", body has "2022") doesn't | |
| # leak the partial date into the company string. | |
| head = partial_date_re.sub("", header_line).strip(" |Β·.,") | |
| parts = re.split(r"[Β·β’|]", head, maxsplit=1) | |
| role = parts[0].strip() if parts else head | |
| company = parts[1].strip() if len(parts) > 1 else "" | |
| # Parse body bullets + sub-section headers, joining multi-line bullets | |
| bullets: list[str] = [] | |
| for raw in body.split("\n"): | |
| line = raw.strip() | |
| if not line: | |
| continue | |
| # Bug C extension: skip orphan year-only lines (e.g. "2022" left | |
| # over from a wrapped date that already went into `dates`). | |
| if re.fullmatch(r"\d{4}", line): | |
| continue | |
| # Skip standalone "Scope:" lines (they're metadata, not bullets) | |
| if line.lower().startswith("scope:"): | |
| bullets.append(f"Β§Β§METAΒ§Β§{line}") | |
| continue | |
| if line.startswith(("β’", "-", "β", "β", "*", "βͺ", "β")): | |
| bullets.append(line.lstrip("β’-ββ*βͺβ ").strip()) | |
| else: | |
| # Bug B: could be sub-section header OR continuation of the | |
| # previous bullet (PDF line-wrapping artifacts). Decide via | |
| # heuristic: continuations start lowercase / with digits / with | |
| # continuation symbols; sub-section headers are title-case. | |
| if self._is_bullet_continuation(line, bullets): | |
| bullets[-1] = bullets[-1] + " " + line | |
| else: | |
| # Sub-section header (e.g. "AI Chatbot β Conversational Conversion Funnel") | |
| bullets.append(f"Β§Β§HEADERΒ§Β§{line}") | |
| sections.append({"role": role, "company": company, "dates": dates, "bullets": bullets}) | |
| return sections | |
| def _is_bullet_continuation(line: str, bullets: list) -> bool: | |
| """ | |
| Heuristic: is this line a wrapped continuation of the previous bullet, | |
| or a new sub-section header? | |
| Wrapped-continuation signals (return True): | |
| - Previous entry was a real bullet (not Β§Β§HEADERΒ§Β§ / Β§Β§METAΒ§Β§) | |
| - AND first char is lowercase, a digit, or a continuation symbol | |
| (β + % & ( [ { ) | |
| - OR first 60 chars contain fewer than 2 Title-Case words (i.e. this | |
| looks like prose, not a title) | |
| """ | |
| if not bullets: | |
| return False | |
| prev = bullets[-1] | |
| if prev.startswith(("Β§Β§HEADERΒ§Β§", "Β§Β§METAΒ§Β§")): | |
| return False | |
| first = line[0] | |
| if first.islower() or first.isdigit(): | |
| return True | |
| if first in "β+%&([{": | |
| return True | |
| # Count Title-Case words in first 60 chars. Sub-section headers | |
| # usually have 2+ ("AI Chatbot", "Payment Conversion Optimization"). | |
| # Continuations usually have 0-1. | |
| head_60 = line[:60] | |
| cap_words = re.findall(r"\b[A-Z][a-z]+", head_60) | |
| if len(cap_words) >= 2: | |
| return False # Title-Case β sub-section header | |
| # Single capital word at start could be either; default to continuation | |
| # since most multi-line wraps DO start with a capital word | |
| return True | |
| def _extract_education(self, text: str) -> str: | |
| """Extract EDUCATION section. Headers must be ALL CAPS to avoid | |
| catching mid-prose words like 'certifications;'.""" | |
| text_norm = _normalize_spaced_text(text) | |
| edu_match = re.search( | |
| r"EDUCATION(?:\s*&\s*CERTIFICATIONS?)?\s*\n(.*?)" | |
| r"(?:\n(?:CERTIFICATIONS\s*\n|SKILLS\s*\n|EXPERIENCE\s*\n|" | |
| r"PROJECTS\s*\n|REFERENCES|LANGUAGES\s*\n|CORE\s+COMPETENCIES)|\Z)", | |
| text_norm, re.DOTALL, | |
| ) | |
| if edu_match: | |
| return edu_match.group(1).strip()[:800] | |
| return "" | |
| def _extract_skills_section(self, text: str) -> list[str]: | |
| """ | |
| Extract skills/competencies from the original resume as fallback when | |
| the LLM returns an empty core_competencies list. ALL CAPS only. | |
| """ | |
| text_norm = _normalize_spaced_text(text) | |
| m = re.search( | |
| r"(?:CORE\s+COMPETENCIES(?:\s*&\s*SKILLS)?|TECHNICAL\s+SKILLS|SKILLS\s*\n)\s*\n?(.*?)" | |
| r"(?:\n(?:EDUCATION|EXPERIENCE|PROJECTS\s*\n|CERTIFICATIONS\s*\n|" | |
| r"LANGUAGES\s*\n|KEY\s+METRICS|AWARDS|REFERENCES)|\Z)", | |
| text_norm, re.DOTALL, | |
| ) | |
| if not m: | |
| return [] | |
| body = m.group(1) | |
| # Skills often look like: "Category: skill1, skill2, skill3" or bullet lists | |
| skills: list[str] = [] | |
| for line in body.splitlines(): | |
| line = line.strip().lstrip("β’-ββ*βͺβ ") | |
| if not line: | |
| continue | |
| # Drop "Category:" prefix | |
| line = re.sub(r"^[A-Z][A-Za-z\s&/]+:\s*", "", line) | |
| # Split on commas / bullets / pipes | |
| for piece in re.split(r"[,β’|]", line): | |
| s = piece.strip().strip(".") | |
| if 2 <= len(s) <= 60 and not s.lower().startswith("language"): | |
| skills.append(s) | |
| # Deduplicate, preserve order | |
| seen = set() | |
| unique = [] | |
| for s in skills: | |
| key = s.lower() | |
| if key not in seen: | |
| seen.add(key) | |
| unique.append(s) | |
| return unique[:30] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TEMPLATE RESUME (no LLM β instant, for all jobs) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _generate_template_resume(self, filepath: str, job: dict) -> str: | |
| """ | |
| Generate a clean DOCX resume from the original resume text. | |
| No LLM customization β just copies the original content with | |
| a targeted header showing the specific company and role. | |
| Instant and works for all jobs. | |
| """ | |
| doc = Document() | |
| for section in doc.sections: | |
| section.top_margin = Inches(0.7) | |
| section.bottom_margin = Inches(0.7) | |
| section.left_margin = Inches(0.8) | |
| section.right_margin = Inches(0.8) | |
| # Name from resume (handles ALL CAPS and Title Case) | |
| candidate_name = _extract_candidate_name(self.resume_text) | |
| # Header | |
| name_para = doc.add_paragraph() | |
| name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| run = name_para.add_run(candidate_name) | |
| run.bold = True | |
| run.font.size = Pt(20) | |
| run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E) | |
| # Contact info | |
| parser = ResumeParser.__new__(ResumeParser) | |
| parser.pdf_path = "" | |
| contact = parser.get_contact_info(self.resume_text) | |
| contact_parts = [v for v in [contact.get("phone"), contact.get("email"), contact.get("linkedin")] if v] | |
| if contact_parts: | |
| cp = doc.add_paragraph(" | ".join(contact_parts)) | |
| cp.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for r in cp.runs: | |
| r.font.size = Pt(10) | |
| doc.add_paragraph("β" * 85) | |
| # Copy ALL resume sections from original text. Skip the header block | |
| # (name, tagline, contact line) that we've already rendered ourselves. | |
| normalized = _normalize_spaced_text(self.resume_text) | |
| lines = normalized.split("\n") | |
| # Find the start of real content: the first known section header keyword. | |
| # An ALL CAPS line that's the candidate's name would otherwise be mistaken | |
| # for a section header, so we match against an explicit keyword list. | |
| _SECTION_KEYWORDS = ( | |
| "PROFESSIONAL SUMMARY", "SUMMARY", "PROFILE", "OBJECTIVE", "ABOUT", | |
| "PROFESSIONAL EXPERIENCE", "WORK EXPERIENCE", "EXPERIENCE", "EMPLOYMENT", | |
| "EDUCATION", "SKILLS", "CORE COMPETENCIES", "TECHNICAL SKILLS", | |
| "KEY ACHIEVEMENTS", "KEY METRICS", "PROJECTS", "CERTIFICATIONS", | |
| ) | |
| content_start = 0 | |
| for i, ln in enumerate(lines): | |
| s = ln.strip().upper() | |
| if any(s.startswith(k) for k in _SECTION_KEYWORDS): | |
| content_start = i | |
| break | |
| # Skip the CORE COMPETENCIES / SKILLS section entirely β keywords | |
| # belong in the summary and bullets, not in a separate skills block. | |
| SKIP_SECTIONS = ("CORE COMPETENCIES", "SKILLS", "TECHNICAL SKILLS", "COMPETENCIES") | |
| in_skip_section = False | |
| for line in lines[content_start:]: | |
| line = line.strip() | |
| if not line: | |
| if not in_skip_section: | |
| doc.add_paragraph() | |
| continue | |
| # Section header detection | |
| is_header = re.match(r"^[A-Z][A-Z\s&]{2,}$", line) and len(line) <= 60 | |
| if is_header: | |
| # Reset skip flag when we hit a new section | |
| upper = line.upper() | |
| if any(upper.startswith(s) for s in SKIP_SECTIONS): | |
| in_skip_section = True | |
| continue # skip the header itself | |
| else: | |
| in_skip_section = False | |
| self._add_section_header(doc, line) | |
| continue | |
| if in_skip_section: | |
| continue # drop everything inside the skills/competencies block | |
| if line.startswith(("β’", "-", "β", "β", "*", "βͺ", "β")): | |
| p = doc.add_paragraph(style="List Bullet") | |
| p.add_run(line.lstrip("β’-ββ*βͺβ ").strip()).font.size = Pt(10.5) | |
| else: | |
| p = doc.add_paragraph(line) | |
| for r in p.runs: | |
| r.font.size = Pt(10.5) | |
| doc.save(filepath) | |
| return filepath | |