Spaces:
Sleeping
Sleeping
fix: designation β Associate Product Manager, filename β Saiteja_Tirunagari_<Company>_Resume.pdf
348ccc1 | 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 | |
| # Recruiter-credible Skills size. The keyword firehose (Maximum-ATS once dumped | |
| # 200 terms into resume.skills) is replaced by a capped Skills pool; surplus | |
| # includable terms are redirected into the Summary + Experience bullets where | |
| # recruiters expect them and where they lift the independent score. Aligns with | |
| # the renderer's 28-item total cap, minus headroom for de-dup at render time. | |
| _SKILLS_DISPLAY_CAP = 26 | |
| # R17 β Non-destructive (append-only) tailoring is the DEFAULT. The candidate's | |
| # real role titles, companies, dates, and existing bullets are preserved | |
| # VERBATIM; keywords are added only via Summary augmentation + appended bullets. | |
| # A caller may opt out with job["_non_destructive"] = False. | |
| NON_DESTRUCTIVE_DEFAULT = True | |
| # Lead-in for appended keyword bullets β a recognizable sentinel so the per-role | |
| # cap stays idempotent across repair passes (and it reads as honest exposure, | |
| # never a "additional relevant skills" dump footer). | |
| _KW_BULLET_PREFIX = "Relevant exposure:" | |
| # Each appended "Relevant exposure:" line packs several gated terms so a few | |
| # extra lines per role can carry many keywords (efficient, recruiter-credible). | |
| _KW_TERMS_PER_BULLET = 5 | |
| # Per-role appended-line caps. The standard (non-max) default stays modest so a | |
| # normal tailor only adds a couple of honest exposure lines; Maximum ATS Mode is | |
| # allowed many more so external coverage can reach the user's 90-100% target | |
| # WHILE titles / companies / dates / existing bullets stay verbatim. | |
| _APPEND_BULLETS_PER_ROLE = 3 | |
| _MAX_ATS_BULLETS_PER_ROLE = 8 | |
| 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 | |
| # ββ Provider fallback chain (spec #5) ββββββββββββββββββββββββββββββββββ | |
| # Per job we try providers in config.LLM_GENERATION.provider_order | |
| # (e.g. kimi -> nvidia_primary -> deterministic). The first provider that | |
| # yields a downloadable READY (internal>=90 AND independent>=90 AND | |
| # readability>=90 AND parse-passed) wins; otherwise we keep the best | |
| # attempt. Jobs still run in parallel across workers; the chain is | |
| # sequential *within* each job. Throughput note: every job starts on the | |
| # primary provider, so the primary key sees the most traffic. | |
| self._active_chain = None | |
| try: | |
| from .providers import build_provider_chain | |
| chain = build_provider_chain(self.llm) | |
| if any(getattr(p, "cfg", {}).get("api_key") for p in chain): | |
| self._active_chain = chain | |
| names = " -> ".join(getattr(p, "name", "?") for p in chain) | |
| print(f"{Fore.CYAN} Provider fallback chain: {names}{Style.RESET_ALL}") | |
| except Exception as _ce: | |
| print(f"{Fore.YELLOW} Provider chain unavailable ({_ce}); using single-model path{Style.RESET_ALL}") | |
| 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, | |
| provider_chain=getattr(self, "_active_chain", None)) | |
| 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) | |
| # Prefer the v2 report (weighted JD match + readability + | |
| # status from the auto-repair pipeline) when available. | |
| v2 = job.get("_v2_report") or {} | |
| sc = v2.get("estimated_scores") or {} | |
| if sc: | |
| a = sc.get("jd_match", a) | |
| job["ats_readability"] = sc.get("ats_readability", 0) | |
| job["jd_match"] = sc.get("jd_match", a) | |
| job["combined_range"] = sc.get("combined_range", "") | |
| job["status"] = job.get("_v2_status", "") | |
| job["review_terms"] = v2.get("review_terms_for_user_review", []) | |
| job["ats_score_before"] = b | |
| job["ats_score_after"] = a | |
| job["ats_improvement"] = max(0, a - b) | |
| st = job.get("status", "") | |
| msg = f"β {co} β JD {a}% Β· {st}" if st else 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, | |
| provider_chain: list = 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) | |
| # ββ Provider fallback chain (spec #5): try providers in order, keep the | |
| # best, stop early on a downloadable READY. βββββββββββββββββββββββββββ | |
| if provider_chain: | |
| try: | |
| chain_path = self._run_provider_chain(job, filepath, provider_chain) | |
| if chain_path: | |
| return chain_path | |
| except Exception as e: | |
| print(f"[provider-chain fallback] {company}: {e}") | |
| 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 _maximize_external_coverage(self, resume, filepath: str, jd_text: str, | |
| base_text: str, include_pool=None, | |
| excluded_kw=None, pasted_terms=None, | |
| confirmed_terms=None, | |
| non_destructive: bool = False) -> dict: | |
| """Maximum ATS Mode: GUARANTEE includable external-style keywords appear | |
| in the exported DOCX, then re-render. | |
| The internal score is not a valid external signal, so we drive placement | |
| off a broad Jobalytics-style EXPECTED set (+ any pasted external terms). | |
| Every term is honesty-gated through `candidate_fit` (max mode): only | |
| action=="include" terms are placed; HIGH/BLOCKED (certs, seniority, | |
| employers, engineering, specialized) are NEVER forced. Includable terms | |
| are placed in Skills (reliable verbatim vehicle) AND woven into bullets | |
| for evidence. Returns the expected set + a per-term debug report. | |
| """ | |
| from .external_ats import extract_external_keywords, external_coverage | |
| from .candidate_fit import classify_fit | |
| from .jd_analyzer import Requirement, _categorize | |
| from .resume_renderer import render_resume_docx | |
| from .ats_scorer import _kw_in_text | |
| include_pool = include_pool or [] | |
| excluded_kw = {e.lower() for e in (excluded_kw or set())} | |
| pasted_terms = pasted_terms or [] | |
| confirmed = {t.lower().strip() for t in (confirmed_terms or [])} | |
| jd_low = jd_text.lower() | |
| # Broad expected set = Jobalytics-style extraction βͺ our LOW/MEDIUM JD | |
| # terms βͺ pasted external terms (ground truth from a checker). | |
| expected = extract_external_keywords( | |
| jd_text, extra=list(include_pool) + list(pasted_terms)) | |
| expected = list(dict.fromkeys([e.lower() for e in expected])) | |
| cur_text = _read_docx_text(filepath).lower() | |
| missing = [t for t in expected if not _kw_in_text(t, cur_text)] | |
| # Honesty gate every missing term (max mode). Only include the safe ones. | |
| includable, gated = [], {} | |
| for t in missing: | |
| if t in excluded_kw: | |
| gated[t] = "high-risk/blocked (gated by fit classifier)" | |
| continue | |
| if t in self._BUZZWORDS or t in self._KEYWORD_BLOCKLIST: | |
| gated[t] = "buzzword/blocklisted (checkers penalise)" | |
| continue | |
| r = Requirement(term=t, category=_categorize(t)) | |
| v = classify_fit(r, base_text, maximum_ats_mode=True, confirmed=confirmed) | |
| if v.action == "include": | |
| includable.append(t) | |
| else: | |
| gated[t] = f"{v.action}: {v.reason}" | |
| # Place: weave the JD-relevant ones into bullets (evidence) + GUARANTEE | |
| # all includable terms in Skills (verbatim β external checkers count them). | |
| # R17: in non-destructive mode (default) NEVER re-weave existing bullets; | |
| # includable terms are carried by appended bullets (already added) + | |
| # Skills (below) + the Summary rescue. Only the legacy aggressive mode | |
| # weaves into existing bullets. | |
| # Place ALL includable JD terms (no artificial cap) so external coverage | |
| # can reach the 90-100% target the user mandated. In non-destructive mode | |
| # they go into appended "Relevant exposure:" lines (titles/companies/ | |
| # dates/existing bullets stay verbatim); the higher per-role cap lets a | |
| # few extra lines per role carry the full keyword set. | |
| weave = [t for t in includable if t in jd_low] | |
| if weave and not non_destructive: | |
| try: | |
| self._weave_keywords_into_bullets(resume, weave[:28], jd_text) | |
| except Exception: | |
| pass | |
| elif weave and non_destructive: | |
| # Append-only: place the JD-relevant includable terms as NEW bullets. | |
| try: | |
| self._append_keyword_bullets( | |
| resume, weave, jd_text, | |
| max_per_role=_MAX_ATS_BULLETS_PER_ROLE) | |
| except Exception: | |
| pass | |
| cur_skills = {s.lower() for s in resume.skills} | |
| add_skills = [t for t in includable if t.lower() not in cur_skills] | |
| resume.skills = self._dedup_keywords_by_lemma(list(resume.skills) + add_skills) | |
| render_resume_docx(resume, filepath) | |
| # The Skills section is now capped to a recruiter-credible size, so a | |
| # term that is ONLY in Skills (not woven into a bullet) can be dropped by | |
| # the renderer cap. Rescue any includable JD term that is still absent | |
| # from the export by carrying it in a natural Summary sentence β surplus | |
| # goes to Summary/Experience, never extra skills lines. Honesty-safe: | |
| # every rescued term is already in `includable` (candidate_fit gated). | |
| final_text = _read_docx_text(filepath) | |
| fl = final_text.lower() | |
| rescue = [t for t in includable if t in jd_low and not _kw_in_text(t, fl)] | |
| if rescue: | |
| self._append_summary_terms(resume, rescue) | |
| # Anything that didn't fit the clean Summary sentence is force-woven | |
| # into the most-relevant Experience bullets (each bullet is its own | |
| # paragraph, so there is no shared comma ceiling). Honesty-safe: only | |
| # candidate_fit-gated includable terms are placed. | |
| render_resume_docx(resume, filepath) | |
| fl = _read_docx_text(filepath).lower() | |
| leftover = [t for t in rescue if not _kw_in_text(t, fl)] | |
| if leftover and not non_destructive: | |
| self._force_weave_into_bullets(resume, leftover) | |
| elif leftover and non_destructive: | |
| # Append-only: never rewrite existing bullets; the Summary rescue | |
| # above already carries these gated terms. | |
| self._append_keyword_bullets( | |
| resume, leftover, jd_text, | |
| max_per_role=_MAX_ATS_BULLETS_PER_ROLE) | |
| render_resume_docx(resume, filepath) | |
| final_text = _read_docx_text(filepath) | |
| # Measure from the re-parsed export (the only truth) + per-term report. | |
| cov = external_coverage(expected, final_text) | |
| present_set = {p.lower() for p in cov["present"]} | |
| exp_low = "\n".join(b for r in resume.roles for b in r.bullets).lower() | |
| skills_low = " ".join(resume.skills).lower() | |
| keywords = [] | |
| for t in expected: | |
| found = t in present_set | |
| if found: | |
| if _kw_in_text(t, exp_low): | |
| section = "Experience bullets" | |
| elif _kw_in_text(t, skills_low): | |
| section = "Skills" | |
| else: | |
| section = "Summary/other" | |
| else: | |
| section = "(not placed)" | |
| keywords.append({ | |
| "keyword": t, | |
| "found_in_export": found, | |
| "section": section, | |
| "reason": "" if found else gated.get(t, "could not place cleanly"), | |
| }) | |
| cov["expected_terms"] = expected | |
| cov["includable"] = includable | |
| cov["gated"] = gated | |
| cov["keywords"] = keywords | |
| cov["coverage_count"] = f"{cov['found']}/{cov['expected']}" | |
| return cov | |
| # R17 contract: EVERY call site of _weave_keywords_into_bullets is gated by | |
| # _non_destructive / non_destructive so the append-only default never | |
| # re-weaves existing bullets (see _generate_resume_v4 + _maximize_external_coverage). | |
| 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 _append_summary_terms(self, resume, terms: list) -> int: | |
| """Carry a small, honesty-gated set of surplus JD terms in a natural | |
| closing Professional Summary sentence β NOT a keyword dump line. | |
| This is the consumer for the redirected overflow: terms are already | |
| approved (they come from include_pool / the candidate_fit-gated | |
| includable set), so we only PLACE them, never source new ones. The | |
| sentence is de-duped against the existing summary and kept well under | |
| the 15-separator anti-spam threshold so `_assert_no_dump_footer` still | |
| holds (no dump footer is ever introduced). | |
| """ | |
| if not terms: | |
| return 0 | |
| cur = resume.summary or "" | |
| cur_low = cur.lower() | |
| fresh, seen = [], set() | |
| for t in terms: | |
| tl = (t or "").strip().lower() | |
| if not tl or tl in seen or tl in cur_low: | |
| continue | |
| seen.add(tl) | |
| fresh.append(self._format_skill_name(t)) | |
| if not fresh: | |
| return 0 | |
| # Comma budget: keep the WHOLE summary paragraph under the anti-spam | |
| # strip threshold (the summary renders as one paragraph). Reserve | |
| # headroom for separators already in the existing summary. | |
| existing_sep = cur.count(",") + cur.count("|") + cur.count("β’") | |
| # Stay strictly under the 15-separator anti-spam threshold: with k items | |
| # we add k-1 commas, so existing + (budget-1) <= 13 < 15. | |
| budget = max(0, 14 - existing_sep) | |
| fresh = fresh[:budget] | |
| if not fresh: | |
| return 0 | |
| if len(fresh) == 1: | |
| joined = fresh[0] | |
| elif len(fresh) == 2: | |
| joined = f"{fresh[0]} and {fresh[1]}" | |
| else: | |
| joined = f"{', '.join(fresh[:-1])}, and {fresh[-1]}" | |
| sentence = f"Broader experience spans {joined}." | |
| sep = " " if cur and not cur.endswith((" ", "\n")) else "" | |
| resume.summary = f"{cur}{sep}{sentence}".strip() | |
| return len(fresh) | |
| # R17 contract: the _force_weave_into_bullets call site is gated by | |
| # non_destructive so the append-only default never rewrites existing bullets. | |
| def _force_weave_into_bullets(self, resume, terms: list) -> int: | |
| """Last-resort placement for surplus includable terms that did not fit | |
| the capped Skills list or the Summary sentence: append a short natural | |
| clause to existing Experience bullets, distributed round-robin so no | |
| single bullet is overloaded. | |
| Each bullet is its own paragraph (well under the anti-spam separator | |
| threshold), so this never produces a dump. Honesty-safe: callers pass | |
| only candidate_fit-gated includable terms β nothing new is sourced. | |
| """ | |
| if not terms or not resume.roles: | |
| return 0 | |
| targets = [(ri, bi) for ri, role in enumerate(resume.roles) | |
| for bi, _ in enumerate(role.bullets)] | |
| if not targets: | |
| return 0 | |
| placed = 0 | |
| ti = 0 | |
| for kw in terms: | |
| ri, bi = targets[ti % len(targets)] | |
| ti += 1 | |
| resume.roles[ri].bullets[bi] = self._weave_clause( | |
| resume.roles[ri].bullets[bi], kw) | |
| placed += 1 | |
| return placed | |
| 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 _append_keyword_bullets(self, resume, terms: list, jd_text: str, | |
| max_per_role: int = _APPEND_BULLETS_PER_ROLE, | |
| terms_per_bullet: int = _KW_TERMS_PER_BULLET) -> int: | |
| """Non-destructive placement: append NEW "Relevant exposure:" bullets at | |
| the END of each role, built from honesty-gated, JD-relevant keyword | |
| terms. EXISTING bullets / titles / companies / dates are NEVER modified. | |
| Each appended bullet PACKS up to `terms_per_bullet` terms (comma-joined) | |
| so a few extra lines per role can carry many keywords. At most | |
| `max_per_role` such lines are appended per role (raise this in Maximum | |
| ATS Mode to reach the 90-100% external target). | |
| Idempotent per role: appended bullets carry `_KW_BULLET_PREFIX`, so the | |
| per-role cap is respected even across repeated repair passes, and a term | |
| already present in an appended line is never duplicated. Callers should | |
| pass already-gated terms (HIGH/BLOCKED excluded); this adds light | |
| filtering only (buzzword/blocklist/JD-relevance/dedup). Returns the count | |
| of bullets appended. | |
| """ | |
| if not terms or not resume.roles: | |
| return 0 | |
| jd_low = (jd_text or "").lower() | |
| # Terms already carried by an existing appended line (any role) β never | |
| # repeat them, so repair passes stay idempotent. | |
| already = set() | |
| for role in resume.roles: | |
| for b in role.bullets: | |
| bs = (b or "").strip() | |
| if bs.startswith(_KW_BULLET_PREFIX): | |
| already.add(bs[len(_KW_BULLET_PREFIX):].strip().rstrip(".").lower()) | |
| already_text = " | ".join(already) | |
| clean, seen = [], set() | |
| for t in terms: | |
| tl = (t or "").strip().lower() | |
| if not tl or tl in seen or len(tl) < 3: | |
| continue | |
| if tl in self._BUZZWORDS or tl in self._KEYWORD_BLOCKLIST: | |
| continue | |
| if len(tl) >= 5 and tl.endswith(("at", "iz", "ic")): | |
| continue # lemmatizer artifact ("integrat", "automat") | |
| if jd_low and tl not in jd_low: | |
| continue # only place JD-relevant terms | |
| if tl in already_text: | |
| continue # already carried by a prior appended line | |
| seen.add(tl) | |
| clean.append(t) | |
| clean = self._dedup_keywords_by_lemma(clean) | |
| if not clean: | |
| return 0 | |
| def _appended_count(role) -> int: | |
| return sum(1 for b in role.bullets | |
| if (b or "").strip().startswith(_KW_BULLET_PREFIX)) | |
| # Pack the clean terms into groups of `terms_per_bullet`. | |
| groups, buf = [], [] | |
| for term in clean: | |
| buf.append(self._format_skill_name(term)) | |
| if len(buf) >= max(1, terms_per_bullet): | |
| groups.append(buf) | |
| buf = [] | |
| if buf: | |
| groups.append(buf) | |
| appended, gi = 0, 0 | |
| roles = resume.roles # index 0 is typically the most recent role | |
| while gi < len(groups): | |
| progressed = False | |
| for role in roles: | |
| if gi >= len(groups): | |
| break | |
| if _appended_count(role) >= max_per_role: | |
| continue | |
| role.bullets.append( | |
| f"{_KW_BULLET_PREFIX} {', '.join(groups[gi])}.") | |
| gi += 1 | |
| appended += 1 | |
| progressed = True | |
| if not progressed: | |
| break # every role is at its cap | |
| return appended | |
| def _apply_non_destructive(self, tailored, base_resume, include_terms: list, | |
| jd_text: str, max_per_role: int = 3) -> int: | |
| """R17 β preserve the candidate's REAL history. Replace `tailored.roles` | |
| with VERBATIM copies of the base resume's roles (title / company / | |
| location / dates / existing bullets unchanged) and add keywords ONLY by | |
| appending <= `max_per_role` honesty-gated, JD-relevant bullets at the END | |
| of each role. The Professional Summary is augmented separately (allowed | |
| by R17). This method NEVER renames a role or edits an existing bullet. | |
| Returns the number of appended bullets. | |
| """ | |
| from .resume_model import Resume | |
| if base_resume is None or not base_resume.roles: | |
| return 0 | |
| # 1. Verbatim role reset β overwrite any (possibly rewritten) LLM roles. | |
| tailored.roles = [ | |
| Resume.from_dict({"name": "", "roles": [{ | |
| "title": r.title, "company": r.company, | |
| "location": r.location, "dates": r.dates, | |
| "bullets": list(r.bullets), | |
| }]}).roles[0] | |
| for r in base_resume.roles | |
| ] | |
| # 2. Honesty-gate the candidate terms (defense-in-depth: the unit-test | |
| # path passes raw terms; the pipeline path pre-gates them). Only | |
| # candidate_fit action=='include' terms survive β certs / seniority / | |
| # employers / specialised engineering are never appended. | |
| safe = [] | |
| try: | |
| from .candidate_fit import classify_fit | |
| from .jd_analyzer import Requirement, _categorize | |
| base_text = base_resume.to_flat_text() | |
| for t in include_terms or []: | |
| req = Requirement(term=t, category=_categorize(t)) | |
| v = classify_fit(req, base_text, maximum_ats_mode=True) | |
| if v.action == "include": | |
| safe.append(t) | |
| except Exception: | |
| safe = list(include_terms or []) | |
| # 3. Append <= max_per_role gated keyword bullets per role. | |
| return self._append_keyword_bullets(tailored, safe, jd_text, max_per_role) | |
| def _run_provider_chain(self, job: dict, filepath: str, | |
| provider_chain: list, | |
| base_resume_override=None) -> str | None: | |
| """Try each provider in order (spec #5). The first provider that yields a | |
| downloadable READY result wins; otherwise keep the best attempt ranked by | |
| (download_allowed, independent_jd_match, internal_jd_match). Every attempt | |
| is recorded in report['provider_attempts'] so the batch table can show | |
| which provider was used and how each performed. | |
| """ | |
| from .fit_gate import READY, READY_REVIEW | |
| best = None # (rank_tuple, docx_bytes, report_dict, status) | |
| attempts: list[dict] = [] | |
| for provider in provider_chain: | |
| pname = getattr(provider, "name", "?") | |
| try: | |
| path = self._generate_resume_v4(job, cfg=None, filepath=filepath, | |
| provider=provider, | |
| base_resume_override=base_resume_override) | |
| except Exception as e: | |
| attempts.append({"provider": pname, "error": str(e)[:140]}) | |
| continue | |
| if not path: | |
| attempts.append({"provider": pname, "error": "no_output"}) | |
| continue | |
| report = job.get("_v2_report", {}) or {} | |
| status = job.get("_v2_status", "") | |
| est = report.get("estimated_scores", {}) or {} | |
| internal = est.get("jd_match", 0) | |
| readability = est.get("ats_readability", 0) | |
| ind = report.get("independent_jd_match", job.get("independent_jd_match", 0)) | |
| dl = bool(report.get("download_allowed")) | |
| attempts.append({ | |
| "provider": pname, | |
| "status": status, | |
| "internal_jd_match": internal, | |
| "independent_jd_match": ind, | |
| "ats_readability": readability, | |
| "schema_quality": getattr(self, "_provider_quality", "ok"), | |
| }) | |
| try: | |
| with open(filepath, "rb") as f: | |
| cur_bytes = f.read() | |
| except Exception: | |
| cur_bytes = None | |
| rank = (1 if dl else 0, ind, internal) | |
| if best is None or rank > best[0]: | |
| best = (rank, cur_bytes, dict(report), status) | |
| # Stop early on a genuinely downloadable READY result. | |
| if dl and status in (READY, READY_REVIEW): | |
| break | |
| if best is None: | |
| return None | |
| # Restore the best attempt's DOCX + report onto the job. | |
| if best[1] is not None: | |
| try: | |
| with open(filepath, "wb") as f: | |
| f.write(best[1]) | |
| except Exception: | |
| pass | |
| report = best[2] | |
| report["provider_attempts"] = attempts | |
| job["_v2_report"] = report | |
| job["_v2_status"] = best[3] | |
| job["status"] = best[3] | |
| job["download_allowed"] = bool(report.get("download_allowed")) | |
| job["independent_jd_match"] = report.get("independent_jd_match", | |
| best[0][1]) | |
| job["quality_flag"] = report.get("quality_flag", "") | |
| job["provider_used"] = report.get("provider_used", | |
| attempts[-1].get("provider", "") if attempts else "") | |
| return filepath | |
| def _generate_resume_v4(self, job: dict, cfg: dict, filepath: str, | |
| provider=None, base_resume_override=None) -> 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()] | |
| # R17: non-destructive (append-only) tailoring is the DEFAULT. A caller | |
| # may opt out via job["_non_destructive"] = False. | |
| _non_destructive = job.get("_non_destructive", NON_DESTRUCTIVE_DEFAULT) | |
| # In Maximum ATS Mode the user mandates 90-100% external coverage, so we | |
| # allow MORE appended "Relevant exposure:" lines per role (titles / | |
| # companies / dates / existing bullets still stay verbatim). Otherwise a | |
| # normal tailor only adds a couple of honest exposure lines. | |
| _append_cap = (_MAX_ATS_BULLETS_PER_ROLE | |
| if job.get("_maximum_ats_mode") else _APPEND_BULLETS_PER_ROLE) | |
| # 1. Load (and cache) the parsed canonical resume. An explicit override | |
| # (used by the evaluation / 20-job harness and offline tests) lets the | |
| # exact production generation logic run without a source PDF. | |
| if base_resume_override is not None: | |
| base_resume = base_resume_override | |
| else: | |
| 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 via a PROVIDER (model-independent) with schema validation. | |
| # `provider` (an LLMProvider) takes precedence; else wrap the raw cfg. | |
| if provider is None: | |
| if not cfg: | |
| return None # Need a model cfg / provider for v4 | |
| from .providers import OpenAICompatProvider | |
| provider = OpenAICompatProvider( | |
| (cfg.get("name") if isinstance(cfg, dict) else None) or "model", | |
| cfg, self.llm) | |
| # external_checker_mode='jobalytics_repair' (spec #8): use the provider's | |
| # jobalytics_repair prompt to PLACE pasted missing keywords; otherwise the | |
| # standard tailor prompt. Either way the deterministic expansion + repair | |
| # loop below still runs, so scoring/gating is identical. | |
| _jb_kw = job.get("_jobalytics_keywords") | |
| if _jb_kw and hasattr(provider, "jobalytics_repair"): | |
| tailored_dict, _prov_quality = provider.jobalytics_repair( | |
| base_resume.to_dict(), jd_text, | |
| job.get("title", ""), job.get("company", ""), | |
| _jb_kw, job.get("_jobalytics_placement", ""), | |
| ) | |
| else: | |
| tailored_dict, _prov_quality = provider.tailor_resume( | |
| base_resume.to_dict(), jd_text, | |
| job.get("title", ""), job.get("company", ""), | |
| job.get("_raw_assessment", {}), | |
| ) | |
| self._provider_used = getattr(provider, "name", "model") | |
| self._provider_quality = _prov_quality # ok | failed_schema | provider_error | |
| # 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 | |
| # Contact is identity β never let the model drop the address line | |
| # (keeps the ATS "address" check green across every provider). | |
| if not tailored.contact.location: | |
| tailored.contact.location = base_resume.contact.location | |
| # 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 "Associate 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, | |
| _is_taxonomy_skill as _istax, | |
| ) | |
| from .jd_analyzer import analyze_jd as _analyze_jd | |
| from .candidate_fit import ( | |
| classify_all_fit, auto_terms as _auto_terms, | |
| high_risk_terms as _high_terms, severity as _severity, | |
| MEDIUM as _MED, HIGH as _HIGH, BLOCKED as _BLK, | |
| ) | |
| try: | |
| jd_low = jd_text.lower() | |
| base_text = base_resume.to_flat_text() | |
| # ββ Candidate Fit Expansion (AUTO_AGGRESSIVE) β classify FIRST so we | |
| # never weave HIGH-risk / blocked terms anywhere. The uploaded resume | |
| # is a BASE PROFILE: auto-include LOW+MEDIUM (MEDIUM flags review); | |
| # HIGH-risk (specialized platforms/compliance/engineering/seniority) | |
| # are gated; BLOCKED (creds/fakes/seniority-jumps) excluded. | |
| req_struct = _analyze_jd(jd_text) | |
| # Maximum ATS Mode + per-request confirmed terms ride along on the | |
| # job dict (set by the API / batch caller). In max mode, normal | |
| # PM/AI/SaaS craft terms are treated as user-confirmed and woven in. | |
| _max_ats = bool(job.get("_maximum_ats_mode")) | |
| _confirmed = job.get("_confirmed_terms") or None | |
| fit_verdicts = classify_all_fit(req_struct, base_text, | |
| maximum_ats_mode=_max_ats, | |
| extra_confirmed=_confirmed) | |
| _excluded_kw = {v.keyword.lower() for v in fit_verdicts | |
| if _severity(v) in (_HIGH, _BLK)} | |
| # 2b. Weave AUTO (LOW+MEDIUM) JD keywords missing from the resume into | |
| # bullets β never the HIGH-risk / blocked ones. | |
| 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) | |
| and 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 | |
| and k.lower() not in _excluded_kw | |
| ] | |
| if missing and not _non_destructive: | |
| self._weave_keywords_into_bullets(tailored, missing, jd_text) | |
| _auto_v = [v for v in _auto_terms(fit_verdicts) if v.category != "seniority"] | |
| include_pool = [v.keyword for v in _auto_v] | |
| medium_set = {v.keyword.lower() for v in _auto_v if _severity(v) == _MED} | |
| high_pool = [v.keyword for v in _high_terms(fit_verdicts) | |
| if v.category != "seniority"] | |
| review_pool = [] # HIGH terms are gated, not auto-woven | |
| # LLM jd_skills that are real JD terms (broadens to AI-checker breadth) | |
| for s in (tailored_dict.get("jd_skills") or []): | |
| if isinstance(s, str): | |
| sl = s.strip().lower() | |
| if (3 <= len(sl) <= 40 and len(sl.split()) <= 4 | |
| and sl not in self._BUZZWORDS | |
| and sl not in self._KEYWORD_BLOCKLIST and sl in jd_low | |
| and sl not in [x.lower() for x in include_pool]): | |
| include_pool.append(sl) | |
| def _build_skill_pool(terms, cap=None): | |
| # Recruiter-credible Skills size for BOTH modes. We no longer | |
| # dump 200 terms into resume.skills (the firehose that produced | |
| # the repeated "Core Competencies:" headers). The highest-signal | |
| # terms (JD-frequency + taxonomy sorted) stay in Skills; surplus | |
| # includable terms are redirected into Summary/Experience instead | |
| # of extra skills lines β coverage is preserved without the dump. | |
| if cap is None: | |
| cap = _SKILLS_DISPLAY_CAP | |
| pool = [] | |
| for k in terms: | |
| kl = k.lower() | |
| if (kl in self._BUZZWORDS or kl in self._KEYWORD_BLOCKLIST | |
| or len(k) < 3 or k in pool): | |
| continue | |
| pool.append(k) | |
| pool = self._dedup_keywords_by_lemma(pool) | |
| pool.sort(key=lambda k: (_istax(k.lower()), jd_low.count(k.lower())), | |
| reverse=True) | |
| return pool[:cap] | |
| tailored.skills = _build_skill_pool(include_pool) | |
| self._review_terms_used = [] # populated if repair pulls risky terms | |
| # R17 (DEFAULT): preserve the candidate's real roles VERBATIM and add | |
| # keywords only as appended bullets (Summary augmentation handled | |
| # above). This overwrites any LLM-rewritten role content, so titles / | |
| # companies / dates / existing bullets are never altered. | |
| if _non_destructive: | |
| self._apply_non_destructive(tailored, base_resume, | |
| include_pool, jd_text, | |
| max_per_role=_append_cap) | |
| # Surplus includable terms that no longer fit in the capped Skills | |
| # list must NOT be lost β recruiters (and the independent score) | |
| # reward keywords evidenced in Summary/Experience, not extra skills | |
| # lines. Route the JD-relevant overflow through the existing weave + | |
| # summary-inject paths. HONESTY: only terms already in include_pool | |
| # (which passed candidate_fit / _maximize_external_coverage gating) | |
| # are redirected β no new terms are sourced, no gating relaxed. | |
| _skills_set = {s.lower() for s in tailored.skills} | |
| overflow = [t for t in include_pool | |
| if t.lower() not in _skills_set and t.lower() in jd_low] | |
| if overflow and not _non_destructive: | |
| try: | |
| self._weave_keywords_into_bullets(tailored, overflow[:18], jd_text) | |
| except Exception: | |
| pass | |
| elif overflow and _non_destructive: | |
| # Append-only: route JD-relevant overflow into NEW bullets | |
| # (respecting the per-role cap) instead of rewriting existing ones. | |
| try: | |
| self._append_keyword_bullets(tailored, overflow, jd_text, | |
| max_per_role=_append_cap) | |
| except Exception: | |
| pass | |
| if overflow: | |
| # A handful of the remaining overflow β one natural Summary | |
| # sentence (no dump line). Only already-approved include_pool | |
| # terms are placed; nothing new is sourced. In Maximum-ATS mode | |
| # the final summary rescue is owned by _maximize_external_coverage | |
| # (which measures the real export), so we avoid double-injecting | |
| # here and leave its comma budget free. | |
| if not _max_ats: | |
| woven = {w.lower() for w in overflow[:18]} | |
| summary_extra = [t for t in overflow if t.lower() not in woven][:6] | |
| if summary_extra: | |
| self._append_summary_terms(tailored, summary_extra) | |
| # Persist what we learned about the candidate into the vault so the | |
| # system strengthens across jobs (and stays consistent). | |
| try: | |
| from .candidate_vault import update_from_fit | |
| update_from_fit(fit_verdicts) | |
| except Exception as _ve: | |
| print(f"[vault] {_ve}") | |
| except Exception as e: | |
| print(f"[fit-expansion] {e}") | |
| self._pending_summary_inject = [] | |
| include_pool, review_pool, req_struct = [], [], None | |
| base_text = base_resume.to_flat_text() | |
| # ββ Render β parse-validate β score β AUTO-REPAIR loop (target 90+) ββ | |
| from .ats_report import build_ats_report | |
| from .fit_gate import (READY, READY_REVIEW, NEEDS_REPAIR, | |
| NEEDS_USER_INPUT, LOW_FIT, PARSE_FAILED) | |
| def _exp_text() -> str: | |
| return "\n".join(b for r in tailored.roles for b in r.bullets) | |
| def _render_score(): | |
| render_resume_docx(tailored, filepath) | |
| parsed = _read_docx_text(filepath) | |
| valid, missing_parts = self._validate_parsed_resume(parsed, tailored) | |
| rep = build_ats_report(base_text, parsed, jd_text, | |
| experience_text=_exp_text(), has_tables=False) | |
| # INDEPENDENT validation (anti-circular): evidence-weighted, no | |
| # plausible/fuzzy credit. This is the score the repair loop must | |
| # also satisfy, so we can't pass just by dumping terms into Skills. | |
| from .ats_validator import validate_resume | |
| val = validate_resume(jd_text, parsed, experience_text=_exp_text(), | |
| base_resume_text=base_text) | |
| rep["independent_jd_match"] = val.independent_jd_match | |
| rep["independent_breakdown"] = val.breakdown | |
| rep["evidenced_terms"] = val.evidenced_terms | |
| rep["skills_only_terms"] = val.skills_only_terms | |
| rep["seniority_ok"] = val.seniority_ok | |
| return parsed, rep, valid, missing_parts, val | |
| status = NEEDS_REPAIR | |
| report = {} | |
| repair_attempts = [] | |
| review_used = False | |
| try: | |
| parsed, report, valid, missing_parts, val = _render_score() | |
| for attempt in range(3): | |
| jm = report["estimated_scores"]["jd_match"] | |
| rd = report["estimated_scores"]["ats_readability"] | |
| ind = val.independent_jd_match | |
| repair_attempts.append({"attempt": attempt, "jd_match": jm, | |
| "ats_readability": rd, | |
| "independent_jd_match": ind, "valid": valid}) | |
| if not valid: | |
| status = PARSE_FAILED | |
| # SUCCESS requires BOTH internal AND independent >= 90 (breaks | |
| # circular scoring β terms must be genuinely evidenced, not just | |
| # listed in Skills). | |
| if valid and jm >= 90 and rd >= 90 and ind >= 90: | |
| status = READY_REVIEW if review_used else READY | |
| break | |
| # ββ Repair. Priority: terms that are MISSING entirely, then | |
| # terms present only in Skills (weave THOSE into bullets so they | |
| # become evidenced and the independent score rises). | |
| # AUTO_AGGRESSIVE: never add/weave HIGH-risk or blocked terms. | |
| _excl = locals().get("_excluded_kw", set()) | |
| missing = [t for t in report.get("missing_terms", []) | |
| if t.lower() not in _excl] | |
| skills_only = [t for t in report.get("skills_only_terms", []) | |
| if t.lower() not in _excl] | |
| cur = {s.lower() for s in tailored.skills} | |
| add = [t for t in (missing + include_pool) | |
| if t.lower() not in cur and t.lower() not in _excl] | |
| # Flag review if any MEDIUM-severity term gets included. | |
| med_added = [t for t in add if t.lower() in medium_set] | |
| if med_added: | |
| review_used = True | |
| self._review_terms_used = list(dict.fromkeys( | |
| getattr(self, "_review_terms_used", []) + med_added)) | |
| # Weave evidence into bullets: missing must-haves + skills-only | |
| # terms (the latter directly lifts the independent score). | |
| weave_now = [t for t in (skills_only + missing) | |
| if t.lower() in jd_low][:18] | |
| if weave_now and not _non_destructive: | |
| try: | |
| self._weave_keywords_into_bullets(tailored, weave_now, jd_text) | |
| except Exception: | |
| pass | |
| elif weave_now and _non_destructive: | |
| # Append-only: add evidence as NEW bullets (per-role cap), | |
| # never rewrite the candidate's existing bullets. | |
| try: | |
| self._append_keyword_bullets(tailored, weave_now, jd_text, | |
| max_per_role=_append_cap) | |
| except Exception: | |
| pass | |
| tailored.skills = _build_skill_pool( | |
| list(tailored.skills) + add, | |
| cap=_SKILLS_DISPLAY_CAP) | |
| parsed, report, valid, missing_parts, val = _render_score() | |
| else: | |
| # Loop exhausted. Re-check the FINAL render (the last repair may | |
| # have just crossed 90 after the top-of-loop check). | |
| jm = report["estimated_scores"]["jd_match"] | |
| rd = report["estimated_scores"]["ats_readability"] | |
| ind = val.independent_jd_match | |
| if not valid: | |
| status = PARSE_FAILED | |
| elif jm >= 90 and rd >= 90 and ind >= 90: | |
| status = READY_REVIEW if review_used else READY | |
| elif jm < 55 or ind < 55: | |
| status = LOW_FIT | |
| else: | |
| # Below 90 with LOW+MEDIUM only. Would the HIGH-risk terms | |
| # (specialized platforms/compliance/engineering the candidate | |
| # must CONFIRM) close the gap? If so, pause for the user. | |
| needs_high = False | |
| if high_pool: | |
| from .ats_scoring_v2 import score_jd_match as _sjm | |
| synth = parsed + "\n" + " . ".join(high_pool) | |
| if _sjm(synth, req_struct).score >= 90: | |
| needs_high = True | |
| status = NEEDS_USER_INPUT if needs_high else NEEDS_REPAIR | |
| report["high_risk_terms_for_confirmation"] = high_pool | |
| except Exception as e: | |
| print(f"[repair-loop] {e}") | |
| try: | |
| render_resume_docx(tailored, filepath) | |
| except Exception: | |
| pass | |
| # ββ Maximum ATS Mode: guarantee external-style keyword coverage ββββββ | |
| # The internal score is NOT a valid external signal (internal 96 vs | |
| # Jobalytics 54). Force every INCLUDABLE broad/pasted term physically | |
| # into the exported DOCX, re-render, and measure coverage from the | |
| # re-parsed file. HIGH/BLOCKED stay gated (no fabrication). | |
| ext_cov = None | |
| if job.get("_maximum_ats_mode") and report: | |
| try: | |
| ext_cov = self._maximize_external_coverage( | |
| tailored, filepath, jd_text, | |
| locals().get("base_text", base_resume.to_flat_text()), | |
| include_pool=locals().get("include_pool", []), | |
| excluded_kw=locals().get("_excluded_kw", set()), | |
| pasted_terms=job.get("_jobalytics_keywords") or [], | |
| confirmed_terms=job.get("_confirmed_terms") or [], | |
| non_destructive=_non_destructive, | |
| ) | |
| # Re-score the re-rendered file so the report reflects the export. | |
| parsed, report2, valid, missing_parts, val = _render_score() | |
| # Preserve fields the status block expects, then merge. | |
| for k, v in report2.items(): | |
| report[k] = v | |
| report["external_coverage"] = ext_cov | |
| report["coverage_report"] = { | |
| "external_keywords_total": ext_cov.get("expected"), | |
| "coverage_count": ext_cov.get("coverage_count"), | |
| "found": ext_cov.get("found"), | |
| "missing": ext_cov.get("missing", [])[:40], | |
| "keywords": ext_cov.get("keywords", []), | |
| "gated": ext_cov.get("gated", {}), | |
| } | |
| except Exception as _me: | |
| print(f"[max-ats] {_me}") | |
| # Postcondition + attach the v2 report/status to the job (for UI/Sheets) | |
| try: | |
| self._assert_no_dump_footer(filepath) | |
| except AssertionError as e: | |
| print(f"[v4 postcondition] {os.path.basename(filepath)}: {e}") | |
| if report: | |
| internal_jm = report.get("estimated_scores", {}).get("jd_match", 0) | |
| independent_jm = report.get("independent_jd_match", 0) | |
| review_list = getattr(self, "_review_terms_used", []) | |
| # ββ Score quality flag (spec #5) ββ | |
| if internal_jm >= 90 and independent_jm < 90: | |
| quality = "WEAK_90_INTERNAL_ONLY" | |
| elif independent_jm >= 90 and review_list: | |
| quality = "REVIEW_REQUIRED_90_PLUS" | |
| elif independent_jm >= 90 and len(report.get("skills_only_terms", [])) > len(report.get("evidenced_terms", [])): | |
| quality = "AGGRESSIVE_90_PLUS" | |
| elif independent_jm >= 90: | |
| quality = "CLEAN_90_PLUS" | |
| else: | |
| quality = "BELOW_90" | |
| # Schema-failed model output must NEVER yield READY (spec #2). | |
| prov_quality = getattr(self, "_provider_quality", "ok") | |
| if prov_quality != "ok" and status in (READY, READY_REVIEW): | |
| status = NEEDS_REPAIR | |
| # Download allowed ONLY if BOTH scores clear 90 (spec: independent | |
| # < 90 must not be downloadable as READY) AND the independent | |
| # seniority check passes (anti-faking: never auto-READY a resume | |
| # for a role the candidate is too junior for). | |
| seniority_ok = report.get("seniority_ok", True) | |
| download_allowed = (status in (READY, READY_REVIEW) | |
| and internal_jm >= 90 and independent_jm >= 90 | |
| and seniority_ok | |
| and prov_quality == "ok") | |
| # Demote a "READY" whose independent score failed. | |
| if status in (READY, READY_REVIEW) and independent_jm < 90: | |
| status = NEEDS_REPAIR | |
| # Honesty gate: a 12-year/Director JD must not be matched as READY | |
| # for a mid-level candidate even if keyword coverage clears 90. | |
| if status in (READY, READY_REVIEW) and not seniority_ok: | |
| status = NEEDS_REPAIR | |
| report["provider_used"] = getattr(self, "_provider_used", "model") | |
| report["provider_response_quality"] = prov_quality | |
| # Structured risky-term review table (spec #7): term | why | where | source | |
| try: | |
| parsed_low = _read_docx_text(filepath).lower() | |
| exp_low = "\n".join(b for r in tailored.roles for b in r.bullets).lower() | |
| skills_low = " ".join(tailored.skills).lower() | |
| risky_table = [] | |
| for v in fit_verdicts: | |
| if v.action not in ("include_carefully", "ask_user"): | |
| continue | |
| tl = v.keyword.lower() | |
| where = [] | |
| if tl in exp_low: | |
| where.append("Experience") | |
| if tl in skills_low: | |
| where.append("Skills") | |
| if not where and tl in parsed_low: | |
| where.append("Summary/other") | |
| if not where: | |
| continue # not actually in the resume β nothing to review | |
| risky_table.append({ | |
| "term": v.keyword, | |
| "why": v.reason, | |
| "where": " + ".join(where), | |
| "source": "JD expansion" if v.fit_status in ("risky",) else "inference", | |
| "review": "review recommended", | |
| }) | |
| report["risky_review_table"] = risky_table | |
| except Exception as _rt: | |
| report["risky_review_table"] = [] | |
| report["status"] = status | |
| report["quality_flag"] = quality | |
| report["download_allowed"] = download_allowed | |
| report["repair_attempts"] = repair_attempts | |
| report["review_terms_for_user_review"] = review_list | |
| # HIGH-risk terms the candidate could confirm to strengthen further | |
| # (not auto-claimed). Always surfaced for transparency. | |
| report.setdefault("high_risk_terms_for_confirmation", | |
| locals().get("high_pool", [])) | |
| # ββ Maximum ATS Mode: status is driven by EXTERNAL coverage, not the | |
| # internal score. Don't accept internal-96/external-low as done. | |
| if job.get("_maximum_ats_mode") and ext_cov is not None: | |
| from .fit_gate import (READY_MAX_ATS_95_PLUS, | |
| READY_90_PLUS_EXTERNAL_ALIGNED, | |
| BELOW_TARGET_REPAIRABLE) | |
| try: | |
| from config import MAXIMUM_ATS as _MAXCFG | |
| _tgt = _MAXCFG.get("target_external_score", 95) | |
| _min = _MAXCFG.get("min_external_score", 90) | |
| except Exception: | |
| _tgt, _min = 95, 90 | |
| ext_pct = ext_cov.get("pct", 0) | |
| gates_ok = (internal_jm >= 90 and independent_jm >= 90 | |
| and report.get("seniority_ok", True) | |
| and report.get("estimated_scores", {}).get("ats_readability", 0) >= 90) | |
| if ext_pct >= _tgt and gates_ok: | |
| status = READY_MAX_ATS_95_PLUS | |
| elif ext_pct >= _min: | |
| status = READY_90_PLUS_EXTERNAL_ALIGNED | |
| elif status not in (READY, READY_REVIEW): | |
| # Below external target but resume is downloadable for review | |
| # and the loop should keep going on the remaining LOW/MEDIUM | |
| # gaps β never silently accept a low external score. | |
| if ext_cov.get("includable") or ext_cov.get("missing"): | |
| status = BELOW_TARGET_REPAIRABLE | |
| report["status"] = status | |
| report["external_coverage_pct"] = ext_pct | |
| # Always offer the download in max mode (review), per spec. | |
| report["download_allowed"] = download_allowed or status in ( | |
| READY_MAX_ATS_95_PLUS, READY_90_PLUS_EXTERNAL_ALIGNED) | |
| job["_v2_report"] = report | |
| job["_v2_status"] = status | |
| job["quality_flag"] = quality | |
| job["download_allowed"] = report.get("download_allowed", download_allowed) | |
| job["independent_jd_match"] = independent_jm | |
| try: | |
| self._log_tailoring_diagnostic( | |
| filepath=filepath, job=job, jd_text=jd_text, | |
| assessed_kw=assessed_kw, customization=tailored_dict, | |
| final_score=(report.get("estimated_scores", {}).get("jd_match", 0) if report else 0), | |
| 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 _validate_parsed_resume(self, parsed_text: str, tailored) -> tuple: | |
| """Post-render parse validation (spec #5/#10). Confirm the EXPORTED file | |
| re-parses to text that still contains every important part. Returns | |
| (is_valid, missing_parts).""" | |
| low = (parsed_text or "").lower() | |
| missing = [] | |
| # Contact (email or phone) | |
| if not re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+|\+?\d[\d\s\-()]{7,}", parsed_text or ""): | |
| missing.append("contact_info") | |
| # Standard headings | |
| for h in ("professional summary", "experience", "skills", "education"): | |
| if h not in low: | |
| missing.append(f"heading:{h}") | |
| # Experience bullets survived. NOTE: the renderer uses Word's "List | |
| # Bullet" style, so re-parsed bullet lines carry NO glyph β count | |
| # substantive content lines (not headings/short meta) instead. | |
| _HEADINGS = {"professional summary", "professional experience", "experience", | |
| "key achievements", "skills", "education", "certifications"} | |
| content_lines = sum( | |
| 1 for ln in (parsed_text or "").splitlines() | |
| if ln.strip() and ln.strip().lower() not in _HEADINGS | |
| and len(ln.split()) >= 6 | |
| ) | |
| if content_lines < 3: | |
| missing.append("experience_bullets") | |
| # Candidate name survived | |
| if tailored is not None and getattr(tailored, "name", ""): | |
| if tailored.name.split()[0].lower() not in low: | |
| missing.append("candidate_name") | |
| # Skills content survived (at least some listed skills present) | |
| if tailored is not None and getattr(tailored, "skills", None): | |
| present = sum(1 for s in tailored.skills if s.lower() in low) | |
| if present < max(3, len(tailored.skills) // 4): | |
| missing.append("skills_content") | |
| # Valid unless a STRUCTURAL part is missing (headings/contact/bullets/name). | |
| structural = [m for m in missing | |
| if m.startswith("heading:") or m in | |
| ("contact_info", "experience_bullets", "candidate_name")] | |
| return (len(structural) == 0, missing) | |
| 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 | |