""" CV Analyzer Module This module analyzes job descriptions to extract relevant skills and requirements, and then modifies a CV template to match those requirements. """ import re import os import logging import pandas as pd from datetime import datetime from docx import Document from docx.shared import Pt import spacy import subprocess import sys from job_apply_ai.utils.helpers import ensure_directory_exists, sanitize_filename # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Try to load spaCy model, with fallback try: nlp = spacy.load("en_core_web_sm") except OSError: logger.warning("SpaCy model not found. Using basic text processing instead.") nlp = None class CVAnalyzer: """ A class to analyze job descriptions and extract relevant skills and requirements. """ def __init__(self): """ Initialize the CV analyzer. """ # Load spaCy model try: self.nlp = spacy.load("en_core_web_sm") except OSError: # If model not found, download it logger.warning("SpaCy model not found. Downloading...") subprocess.run([sys.executable, "-m", "spacy", "download", "en_core_web_sm"], check=True) self.nlp = spacy.load("en_core_web_sm") # Define skill categories self.skill_categories = { "Programming Languages": [ "python", "java", "javascript", "c++", "c#", "ruby", "php", "swift", "kotlin", "go", "rust", "typescript", "scala", "perl", "r", "matlab", "bash", "shell", "powershell", "sql", "html", "css", "dart" ], "Frameworks & Libraries": [ "react", "angular", "vue", "django", "flask", "spring", "express", "node.js", "tensorflow", "pytorch", "scikit-learn", "pandas", "numpy", "bootstrap", "jquery", "laravel", "symfony", "rails", "asp.net", "flutter", "xamarin", ".net", "dotnet", "core", "entity framework" ], "Databases": [ "mysql", "postgresql", "mongodb", "sqlite", "oracle", "sql server", "cassandra", "redis", "elasticsearch", "dynamodb", "mariadb", "neo4j", "firebase", "supabase", "cockroachdb", "couchdb", "cosmosdb" ], "Cloud & DevOps": [ "aws", "azure", "gcp", "google cloud", "docker", "kubernetes", "jenkins", "terraform", "ansible", "chef", "puppet", "circleci", "travis", "github actions", "gitlab ci", "bitbucket pipelines", "heroku", "netlify", "vercel", "digitalocean", "linode", "cloudflare", "akamai", "fastly", "lambda", "ec2", "s3", "rds" ], "Tools & Platforms": [ "git", "github", "gitlab", "bitbucket", "jira", "confluence", "trello", "slack", "notion", "figma", "sketch", "adobe xd", "photoshop", "illustrator", "visual studio", "vs code", "intellij", "pycharm", "eclipse", "android studio", "xcode", "postman", "insomnia", "swagger", "sentry", "datadog", "grafana" ], "Methodologies": [ "agile", "scrum", "kanban", "waterfall", "lean", "tdd", "bdd", "ci/cd", "devops", "devsecops", "gitflow", "trunk-based development", "pair programming", "extreme programming", "safe", "prince2", "pmp", "itil", "togaf" ], "Soft Skills": [ "communication", "teamwork", "leadership", "problem solving", "critical thinking", "time management", "adaptability", "creativity", "emotional intelligence", "conflict resolution", "negotiation", "presentation", "public speaking", "customer service", "mentoring", "coaching", "decision making" ], "Languages": [ "english", "german", "french", "spanish", "italian", "portuguese", "dutch", "swedish", "norwegian", "danish", "finnish", "russian", "chinese", "japanese", "korean", "arabic", "hindi", "bengali", "urdu", "turkish", "polish", "ukrainian" ], "Business & Analytics": [ "excel", "powerpoint", "word", "tableau", "power bi", "looker", "google analytics", "seo", "sem", "google ads", "facebook ads", "marketing", "sales", "crm", "erp", "salesforce", "hubspot", "zoho", "mailchimp", "google workspace", "office 365", "financial analysis", "forecasting", "budgeting", "accounting", "quickbooks", "sap" ] } def extract_skills_from_description(self, description): """ Extract relevant skills and requirements from a job description. Args: description (str): Job description text. Returns: tuple: (matched_skills, matched_requirements, matched_categories) """ if not description: logger.warning("Empty job description provided") return [], [], {} # Process the text doc = self.nlp(description.lower()) # Extract all potential skills (nouns and noun phrases) potential_skills = [] for chunk in doc.noun_chunks: potential_skills.append(chunk.text) # Add single tokens that might be skills for token in doc: if token.pos_ in ["NOUN", "PROPN"]: potential_skills.append(token.text) # Clean up skills cleaned_skills = [] for skill in potential_skills: # Remove punctuation and extra whitespace cleaned = re.sub(r'[^\w\s]', '', skill).strip() if cleaned and len(cleaned) > 1: # Avoid single characters cleaned_skills.append(cleaned) # Match skills to categories matched_skills = set() matched_requirements = [] matched_categories = {} desc_lower = description.lower() # Create a flattened list of all skills for faster lookup all_skills_flat = {} for category, skills in self.skill_categories.items(): for skill in skills: all_skills_flat[skill] = category # Match skills with stricter rules to avoid noisy fragments (e.g. "r" from "error"). ambiguous_short_skills = { "r", "go", "c", "js", "core", "word", "sales" } for skill in cleaned_skills: # Check for exact matches if skill in all_skills_flat: if skill in ambiguous_short_skills: continue matched_skills.add(skill) category = all_skills_flat[skill] if category not in matched_categories: matched_categories[category] = [] if skill not in matched_categories[category]: matched_categories[category].append(skill) else: # Check for safer word-boundary partial matches. for known_skill, category in all_skills_flat.items(): if known_skill in ambiguous_short_skills: continue if len(known_skill) < 3: continue pattern = r'(? 0: title_part = summary[:marker_idx].strip() detail_part = summary[marker_idx + 1:].strip() else: parts = summary.split('. ', 1) title_part = parts[0].strip() detail_part = parts[1].strip() if len(parts) > 1 else summary changed = False for node in nodes: text = (node.text or '').strip() if text == "Senior Web Developer": node.text = title_part changed = True elif text.startswith("specializing in front end development"): node.text = detail_part or summary changed = True return changed def find_skills_section(self): """ Find the skills section in the CV template. Returns: tuple: (paragraph index, paragraph) or (None, None) if not found """ # Common section titles that might indicate skills skill_section_keywords = [ 'skills', 'technical skills', 'core skills', 'key skills', 'competencies', 'expertise', 'qualifications', 'proficiencies', 'abilities', 'capabilities', 'technical competencies', 'professional skills', 'skill set', 'technical expertise', 'core competencies' ] # First try to find an exact heading match for i, para in enumerate(self.doc.paragraphs): text = para.text.lower().strip() if text in skill_section_keywords or any(text.startswith(kw) for kw in skill_section_keywords): logger.info(f"Found skills section at paragraph {i}: '{para.text}'") return i, para # If no exact match, try to find a paragraph containing skills keywords for i, para in enumerate(self.doc.paragraphs): text = para.text.lower().strip() if any(kw in text for kw in skill_section_keywords): logger.info(f"Found potential skills section at paragraph {i}: '{para.text}'") return i, para # If still not found, look for bullet points that might contain skill-related words skill_related_words = ['proficient', 'experienced', 'knowledge', 'familiar', 'expert', 'advanced', 'intermediate', 'beginner'] for i, para in enumerate(self.doc.paragraphs): text = para.text.lower().strip() if text.startswith('•') or text.startswith('-') or text.startswith('*'): if any(word in text for word in skill_related_words): # This might be part of a skills section, look for a heading above it if i > 0: logger.info(f"Found potential skills bullet point at paragraph {i}: '{para.text}'") # Return the paragraph before this one as it might be the heading return i-1, self.doc.paragraphs[i-1] logger.warning("Could not find skills section in CV template") return None, None def find_profile_section(self): """ Find the profile/summary section in the CV template. Returns: tuple: (paragraph index, paragraph) or (None, None) if not found """ profile_section_keywords = [ 'profile', 'summary', 'professional summary', 'about me', 'career summary', 'objective', 'personal profile' ] for i, para in enumerate(self.doc.paragraphs): text = para.text.lower().strip() if text in profile_section_keywords or any(text.startswith(kw) for kw in profile_section_keywords): logger.info(f"Found profile section at paragraph {i}: '{para.text}'") return i, para for i, para in enumerate(self.doc.paragraphs): text = para.text.lower().strip() if any(kw in text for kw in profile_section_keywords): logger.info(f"Found potential profile section at paragraph {i}: '{para.text}'") return i, para logger.warning("Could not find profile section in CV template") return None, None def update_profile_summary(self, summary_text): """ Update profile summary text while preserving template layout. Args: summary_text (str): Professional summary text Returns: bool: True if successfully updated or created """ if not summary_text or not str(summary_text).strip(): return False # For heavily designed templates, update existing textbox content in place. if self._update_designed_template_summary(summary_text): return True profile_idx, profile_para = self.find_profile_section() if profile_idx is None: logger.info("Creating new profile section in CV") heading = self.doc.add_paragraph("Professional Summary") applied = self._apply_style_if_available(heading, ['Heading 2', 'Heading 1', 'Title']) if not applied: for run in heading.runs: run.bold = True run.font.size = Pt(14) body = self._insert_paragraph_after(heading, summary_text.strip()) self._apply_style_if_available(body, ['Normal', 'List Paragraph']) return True # Write into first paragraph after the profile heading until next heading. next_section_idx = None for i in range(profile_idx + 1, len(self.doc.paragraphs)): style_name = self.doc.paragraphs[i].style.name if self.doc.paragraphs[i].style else "" if style_name.startswith('Heading'): next_section_idx = i break if next_section_idx is None: next_section_idx = len(self.doc.paragraphs) target_idx = profile_idx + 1 if target_idx < next_section_idx: target_para = self.doc.paragraphs[target_idx] target_para.text = summary_text.strip() self._apply_style_if_available(target_para, ['Normal', 'List Paragraph']) # Remove extra old summary paragraphs below the first line. for i in reversed(range(target_idx + 1, next_section_idx)): p = self.doc.paragraphs[i] p._element.getparent().remove(p._element) return True body = self._insert_paragraph_after(profile_para, summary_text.strip()) self._apply_style_if_available(body, ['Normal', 'List Paragraph']) return True def update_skills_section(self, matched_categories): """ Update the skills section in the CV with matched skills. Args: matched_categories (dict): Dictionary of skill categories and their skills Returns: bool: True if successful, False otherwise """ if not matched_categories: logger.warning("No matched categories provided") return False # For designed templates (text boxes/shapes), update existing highlights in place. if self._update_designed_template_skills(matched_categories): logger.info("Updated skills in designed template text boxes") return True # Find the skills section skills_idx, skills_para = self.find_skills_section() if skills_idx is None: # Create a new skills section if one doesn't exist logger.info("Creating new skills section in CV") skills_para = self.doc.add_paragraph() skills_para.text = "Skills" applied = self._apply_style_if_available(skills_para, ['Heading 2', 'Heading 1', 'Title']) if not applied: # If no heading styles exist, make the heading visibly distinct. for run in skills_para.runs: run.bold = True run.font.size = Pt(14) skills_idx = len(self.doc.paragraphs) - 1 # Clear existing content after the skills heading # Find where the next section starts next_section_idx = None for i in range(skills_idx + 1, len(self.doc.paragraphs)): if self.doc.paragraphs[i].style.name.startswith('Heading'): next_section_idx = i break # If no next section found, we'll add skills at the end if next_section_idx is None: next_section_idx = len(self.doc.paragraphs) # Remove paragraphs between skills heading and next section # We need to be careful here as removing paragraphs changes indices # So we'll work backwards paragraphs_to_remove = list(range(skills_idx + 1, next_section_idx)) for i in reversed(paragraphs_to_remove): if i < len(self.doc.paragraphs): p = self.doc.paragraphs[i] p._element.getparent().remove(p._element) # Add matched skills by category anchor_para = skills_para for category, skills in matched_categories.items(): if skills: # Only add categories with skills # Keep output concise and deterministic. clean_skills = [] seen = set() for s in skills: formatted = self._format_skill(s) key = formatted.lower() if key and key not in seen: clean_skills.append(formatted) seen.add(key) clean_skills = clean_skills[:8] if not clean_skills: continue # Add category heading and skills immediately after it, # preserving original document flow and section placement. category_para = self._insert_paragraph_after(anchor_para) category_para.text = category applied = self._apply_style_if_available(category_para, ['Heading 3', 'Heading 2', 'Heading 1']) if not applied: for run in category_para.runs: run.bold = True run.font.size = Pt(12) details_para = self._insert_paragraph_after(category_para) details_para.text = ", ".join(clean_skills) self._apply_style_if_available(details_para, ['List Paragraph', 'Normal']) anchor_para = details_para logger.info("Successfully updated skills section in CV") return True def save_modified_cv(self, output_path): """ Save the modified CV to the specified path. Args: output_path (str): Path to save the modified CV Returns: bool: True if successful, False otherwise """ try: # Ensure the directory exists os.makedirs(os.path.dirname(output_path), exist_ok=True) # Save the document self.doc.save(output_path) logger.info(f"Saved modified CV to {output_path}") return True except Exception as e: logger.error(f"Error saving modified CV: {str(e)}") return False def process_multiple_jobs(self, jobs_df, output_dir=None): """ Process multiple jobs and create tailored CVs for each. Args: jobs_df (DataFrame): DataFrame containing job listings with extracted skills. output_dir (str, optional): Directory to save the tailored CVs. If None, uses default. Returns: list: List of paths to the generated CV files. """ if jobs_df.empty: logger.warning("Empty DataFrame provided") return [] if output_dir is None: output_dir = os.path.join(os.getcwd(), "job_apply_ai", "outputs", "cvs") # Ensure the output directory exists ensure_directory_exists(output_dir) # Get current date for filename today_date = datetime.today().strftime("%Y-%m-%d") generated_cvs = [] for idx, row in jobs_df.iterrows(): job_title = row.get('title', f"Job_{idx+1}") company = row.get('company', "Company") matched_categories = row.get('Skill Categories', {}) if not matched_categories: logger.warning(f"No skills found for job: {job_title} at {company}") continue # Create a fresh copy of the template for each job self.doc = Document(self.cv_template_path) # Update the skills section if self.update_skills_section(matched_categories): # Generate a filename with date, company, and job title safe_company = sanitize_filename(company) safe_title = sanitize_filename(job_title) filename = f"CV_{today_date}_{safe_company}_{safe_title}.docx" output_path = os.path.join(output_dir, filename) # Save the modified CV if self.save_modified_cv(output_path): generated_cvs.append(output_path) logger.info(f"Generated CV for {job_title} at {company}") else: logger.warning(f"Failed to update CV for job: {job_title} at {company}") return generated_cvs def batch_process_jobs(jobs_file, cv_template, output_dir=None): """ Batch process multiple jobs from an Excel file and generate tailored CVs. Args: jobs_file (str): Path to Excel file containing job listings. cv_template (str): Path to CV template (.docx). output_dir (str, optional): Directory to save the tailored CVs. Returns: list: List of paths to the generated CV files. """ try: # Load jobs jobs_df = pd.read_excel(jobs_file) if jobs_df.empty: logger.warning(f"No jobs found in {jobs_file}") return [] # Process job descriptions analyzer = CVAnalyzer() processed_df = analyzer.process_job_descriptions(jobs_df) # Create tailored CVs modifier = CVModifier(cv_template) generated_cvs = modifier.process_multiple_jobs(processed_df, output_dir) return generated_cvs except Exception as e: logger.error(f"Error in batch processing: {str(e)}") return [] def main(): """ Main function to demonstrate the CV analyzer and modifier. """ # Example usage import os # 1. Load job descriptions from Excel jobs_file = input("Enter path to jobs Excel file: ") if not os.path.exists(jobs_file): print(f"File not found: {jobs_file}") return # 2. Load CV template cv_template = input("Enter path to your CV template (.docx): ") if not os.path.exists(cv_template): print(f"File not found: {cv_template}") return # 3. Set output directory output_dir = os.path.join(os.getcwd(), "job_apply_ai", "outputs", "cvs") # 4. Process all jobs and generate CVs generated_cvs = batch_process_jobs(jobs_file, cv_template, output_dir) if generated_cvs: print(f"\n✅ Generated {len(generated_cvs)} tailored CVs:") for cv_path in generated_cvs: print(f" - {cv_path}") else: print("\n❌ Failed to generate any CVs") if __name__ == "__main__": main()