""" Component 3: CV Generator Section-by-section CV rewriting using LLM (via HF Inference API). Smart truncation: drops irrelevant content, keeps only what helps match the JD. Output MUST fit on 1 page. """ import os import re import logging from typing import Dict, List, Optional, Generator from huggingface_hub import InferenceClient logger = logging.getLogger(__name__) DEFAULT_MODEL = "Qwen/Qwen2.5-72B-Instruct" API_TIMEOUT = 120 def _get_client(hf_token=None): token = hf_token or os.environ.get("HF_TOKEN", "") return InferenceClient(api_key=token, timeout=API_TIMEOUT) SYSTEM_PROMPT = """You are an expert resume writer and ATS optimization specialist. YOUR TASK: Rewrite the candidate's resume into a CONCISE, 1-PAGE, ATS-optimized version for the target job. HARD CONSTRAINT: The output MUST fit on a single page. Be ruthlessly concise. RULES: 1. NEVER fabricate experiences, skills, companies, titles, dates, or achievements. 2. AGGRESSIVELY cut irrelevant content. Only keep what helps land THIS specific job. 3. Experience: max 3-4 bullets per role. Drop irrelevant roles entirely or compress to 1 line. 4. Summary: 2-3 sentences max. 5. Skills: only job-relevant skills. One compact paragraph per category. 6. Education: 1-2 lines max. 7. Integrate target keywords where they truthfully apply. 8. Strong action verbs. Quantify where numbers exist. Never invent numbers. 9. Standard ATS headers. Professional tone. No first person. 10. Bullet points start with "• ". NO markdown formatting. OUTPUT: Plain text only. Section headers on own line. Keep total output under 500 words.""" def _build_section_prompt(section_name, section_content, keywords, job_description, candidate_name=""): kw_str = ', '.join(keywords[:10]) jd_short = job_description[:400] prompts = { "summary": f"""Write a 2-3 sentence Professional Summary for this job. Be concise. Keywords to include: {kw_str} ORIGINAL: {section_content} JOB: {jd_short}""", "experience": f"""Rewrite Experience, HEAVILY trimmed for this job. - Keep job title, company, dates exactly - MAX 3-4 bullets per role — only the most relevant ones - DROP irrelevant roles or compress to 1 line - Use action verbs + these keywords where truthful: {kw_str} - NEVER add anything not in the original ORIGINAL: {section_content} JOB: {jd_short}""", "skills": f"""Rewrite Skills as a compact list for ATS. - Group: Technical | Tools | Soft Skills - Comma-separated, 1 line per group - Only job-relevant skills. Drop unrelated ones. - Use exact JD terminology where matching ORIGINAL: {section_content} KEYWORDS: {kw_str}""", "education": f"""Format Education in 1-2 lines max. Degree | Institution | Date. Only relevant details. ORIGINAL: {section_content}""", "certifications": f"""List relevant certifications only. 1 line each. ORIGINAL: {section_content}""", "projects": f"""Keep only 1-2 most relevant projects. 2 lines each max. Keywords: {kw_str} ORIGINAL: {section_content} JOB: {jd_short}""", } return prompts.get(section_name, f"""Rewrite concisely for this job. Keywords: {kw_str} ORIGINAL: {section_content}""") def _call_llm_streaming(client, model, system_prompt, user_prompt, max_tokens=1000): """Call LLM with streaming. Safe chunk handling.""" logger.info(f"LLM call: {model} (max_tokens={max_tokens})") try: stream = client.chat_completion( model=model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], max_tokens=max_tokens, temperature=0.3, stream=True, ) for chunk in stream: try: if chunk.choices and len(chunk.choices) > 0: delta = chunk.choices[0].delta if delta and hasattr(delta, 'content') and delta.content: yield delta.content except (IndexError, AttributeError): continue except Exception as e: error_str = str(e) logger.error(f"LLM error: {error_str}") if "429" in error_str: yield "\n⚠️ Rate limited — try 'Qwen 2.5 7B (Fast)'." elif "503" in error_str: yield "\n⚠️ Model unavailable — try 'Qwen 2.5 7B (Fast)'." else: yield f"\n⚠️ LLM error: {error_str}" def generate_cv_full(parsed_cv, keywords_data, job_description, hf_token=None, model=DEFAULT_MODEL): """Generate 1-page ATS CV. Yields progressive output.""" client = _get_client(hf_token) name = parsed_cv.get("name", "Candidate") contact = parsed_cv.get("contact", "") sections = parsed_cv.get("sections", {}) raw_text = parsed_cv.get("raw_text", "") required_kws = keywords_data.get("required_keywords", []) missing_kws = keywords_data.get("missing_keywords", []) priority_keywords = missing_kws[:10] + [k for k in required_kws if k not in missing_kws][:5] full_output = f"{name}\n{contact}\n\n" yield full_output section_order = ["summary", "experience", "skills", "education", "certifications", "projects"] if not sections or len(sections) <= 1: yield full_output + "⏳ Generating tailored 1-page CV...\n" prompt = f"""Rewrite this resume into a CONCISE 1-page version for the target job. Cut everything irrelevant. Keep only what matches. Under 500 words total. Keywords: {', '.join(priority_keywords[:15])} ORIGINAL: {raw_text} JOB: {job_description[:600]}""" partial = full_output for chunk in _call_llm_streaming(client, model, SYSTEM_PROMPT, prompt, max_tokens=1500): partial += chunk yield partial yield partial return for section_name in section_order: content = sections.get(section_name, "") if not content: continue yield full_output + f"⏳ Tailoring {section_name.title()}...\n" logger.info(f"Rewriting: {section_name}") prompt = _build_section_prompt(section_name, content, priority_keywords, job_description, name) # Tight token limits for 1-page output max_tok = 800 if section_name == "experience" else 400 section_text = "" for chunk in _call_llm_streaming(client, model, SYSTEM_PROMPT, prompt, max_tokens=max_tok): section_text += chunk yield full_output + section_text + "\n" if section_text.strip(): full_output += section_text.strip() + "\n\n" else: full_output += f"{section_name.upper()}\n{content}\n\n" yield full_output skip = {"interests", "hobbies", "references", "preamble"} for section_name, content in sections.items(): if section_name in section_order or section_name.lower() in skip: continue full_output += f"{section_name.upper()}\n{content}\n\n" yield full_output yield full_output def generate_cv_batch(parsed_cv, keywords_data, job_description, hf_token=None, model=DEFAULT_MODEL): result = "" for output in generate_cv_full(parsed_cv, keywords_data, job_description, hf_token, model): result = output return result