| """ |
| 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 |
|
|
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' |
| ) |
| logger = logging.getLogger(__name__) |
|
|
| |
| 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. |
| """ |
| |
| try: |
| self.nlp = spacy.load("en_core_web_sm") |
| except OSError: |
| |
| 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") |
| |
| |
| 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 [], [], {} |
| |
| |
| doc = self.nlp(description.lower()) |
| |
| |
| potential_skills = [] |
| for chunk in doc.noun_chunks: |
| potential_skills.append(chunk.text) |
| |
| |
| for token in doc: |
| if token.pos_ in ["NOUN", "PROPN"]: |
| potential_skills.append(token.text) |
| |
| |
| cleaned_skills = [] |
| for skill in potential_skills: |
| |
| cleaned = re.sub(r'[^\w\s]', '', skill).strip() |
| if cleaned and len(cleaned) > 1: |
| cleaned_skills.append(cleaned) |
| |
| |
| matched_skills = set() |
| matched_requirements = [] |
| matched_categories = {} |
| desc_lower = description.lower() |
| |
| |
| all_skills_flat = {} |
| for category, skills in self.skill_categories.items(): |
| for skill in skills: |
| all_skills_flat[skill] = category |
| |
| |
| ambiguous_short_skills = { |
| "r", "go", "c", "js", "core", "word", "sales" |
| } |
|
|
| for skill in cleaned_skills: |
| |
| 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: |
| |
| 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'(?<!\\w)' + re.escape(known_skill) + r'(?!\\w)' |
| if re.search(pattern, skill): |
| matched_skills.add(known_skill) |
| if category not in matched_categories: |
| matched_categories[category] = [] |
| if known_skill not in matched_categories[category]: |
| matched_categories[category].append(known_skill) |
|
|
| |
| for known_skill, category in all_skills_flat.items(): |
| if known_skill in ambiguous_short_skills: |
| continue |
| if len(known_skill) < 2: |
| continue |
| pattern = r'(?<!\\w)' + re.escape(known_skill) + r'(?!\\w)' |
| if re.search(pattern, desc_lower): |
| matched_skills.add(known_skill) |
| if category not in matched_categories: |
| matched_categories[category] = [] |
| if known_skill not in matched_categories[category]: |
| matched_categories[category].append(known_skill) |
| |
| |
| requirement_keywords = ["required", "must", "should", "need", "essential", "necessary"] |
| for sent in doc.sents: |
| has_modal = any(token.pos_ == "AUX" and token.dep_ == "aux" for token in sent) |
| has_requirement = any(keyword in sent.text.lower() for keyword in requirement_keywords) |
| |
| if has_modal or has_requirement: |
| matched_requirements.append(sent.text) |
| |
| |
| if not matched_skills: |
| logger.warning("No skills matched using standard approach, trying aggressive matching") |
| |
| for word in doc: |
| word_text = word.text.lower() |
| if word_text in all_skills_flat: |
| matched_skills.add(word_text) |
| category = all_skills_flat[word_text] |
| if category not in matched_categories: |
| matched_categories[category] = [] |
| if word_text not in matched_categories[category]: |
| matched_categories[category].append(word_text) |
| |
| |
| if not matched_skills and hasattr(doc, 'user_data') and 'job_title' in doc.user_data: |
| job_title = doc.user_data['job_title'].lower() |
| logger.warning(f"No skills matched, adding generic skills based on job title: {job_title}") |
| |
| |
| title_to_skills = { |
| "developer": ["Programming Languages", "Frameworks & Libraries"], |
| "engineer": ["Programming Languages", "Cloud & DevOps"], |
| "data": ["Business & Analytics", "Programming Languages"], |
| "analyst": ["Business & Analytics", "Databases"], |
| "manager": ["Methodologies", "Soft Skills"], |
| "designer": ["Tools & Platforms", "Soft Skills"], |
| "marketing": ["Business & Analytics", "Soft Skills"], |
| "sales": ["Business & Analytics", "Soft Skills"], |
| "support": ["Soft Skills", "Tools & Platforms"], |
| "admin": ["Tools & Platforms", "Business & Analytics"] |
| } |
| |
| for keyword, categories in title_to_skills.items(): |
| if keyword in job_title: |
| for category in categories: |
| if category not in matched_categories: |
| matched_categories[category] = [] |
| |
| skills_to_add = self.skill_categories[category][:3] |
| for skill in skills_to_add: |
| if skill not in matched_categories[category]: |
| matched_categories[category].append(skill) |
| matched_skills.add(skill) |
| |
| |
| if not matched_skills: |
| logger.warning("No skills matched, adding generic soft skills") |
| category = "Soft Skills" |
| matched_categories[category] = self.skill_categories[category][:5] |
| matched_skills.update(matched_categories[category]) |
| |
| logger.info(f"Extracted {len(matched_skills)} skills across {len(matched_categories)} categories") |
| return list(matched_skills), matched_requirements, matched_categories |
| |
| def process_job_descriptions(self, jobs_df, desc_col="description", title_col="title"): |
| """ |
| Process job descriptions in a DataFrame to extract skills and requirements. |
| |
| Args: |
| jobs_df (DataFrame): DataFrame containing job listings. |
| desc_col (str): Column name for job descriptions. |
| title_col (str): Column name for job titles. |
| |
| Returns: |
| DataFrame: Updated DataFrame with extracted skills and requirements. |
| """ |
| if jobs_df.empty: |
| logger.warning("Empty DataFrame provided") |
| return jobs_df |
| |
| skills_list = [] |
| requirements_list = [] |
| categories_list = [] |
| |
| for idx, row in jobs_df.iterrows(): |
| description_text = str(row.get(desc_col, "")) |
| job_title = str(row.get(title_col, "No Title Provided")) |
| |
| if not description_text.strip(): |
| logger.warning(f"Empty description for job: {job_title}") |
| skills_list.append([]) |
| requirements_list.append([]) |
| categories_list.append({}) |
| continue |
| |
| matched_skills, matched_requirements, matched_categories = self.extract_skills_from_description(description_text) |
| skills_list.append(matched_skills) |
| requirements_list.append(matched_requirements) |
| categories_list.append(matched_categories) |
| |
| logger.info(f"Processed job: {job_title} - Found {len(matched_skills)} matching skills") |
| |
| jobs_df["Extracted Skills"] = skills_list |
| jobs_df["Extracted Requirements"] = requirements_list |
| jobs_df["Skill Categories"] = categories_list |
| |
| return jobs_df |
|
|
|
|
| class CVModifier: |
| """ |
| A class to modify a CV template based on extracted job requirements. |
| """ |
| |
| def __init__(self, cv_template_path): |
| """ |
| Initialize the CV modifier with a template document. |
| |
| Args: |
| cv_template_path (str): Path to the CV template document (.docx). |
| """ |
| self.cv_template_path = cv_template_path |
| self.doc = None |
| self.load_template() |
| |
| def load_template(self): |
| """ |
| Load the CV template document. |
| """ |
| try: |
| self.doc = Document(self.cv_template_path) |
| logger.info(f"Loaded CV template from {self.cv_template_path}") |
| except Exception as e: |
| logger.error(f"Error loading CV template: {str(e)}") |
| raise |
|
|
| def _get_first_available_style(self, preferred_styles): |
| """Return the first style name that exists in the current document.""" |
| available = {s.name for s in self.doc.styles} |
| for style_name in preferred_styles: |
| if style_name in available: |
| return style_name |
| return None |
|
|
| def _apply_style_if_available(self, paragraph, preferred_styles): |
| """Apply the first available style from preferred_styles; keep default if none exist.""" |
| style_name = self._get_first_available_style(preferred_styles) |
| if style_name: |
| paragraph.style = style_name |
| return style_name |
| return None |
|
|
| def _insert_paragraph_after(self, anchor_paragraph, text=""): |
| """Insert a paragraph immediately after anchor_paragraph and return it.""" |
| paragraph = self.doc.add_paragraph(text) |
| anchor_paragraph._p.addnext(paragraph._p) |
| return paragraph |
|
|
| def _format_skill(self, skill_text): |
| """Format skill labels for cleaner CV output.""" |
| if not skill_text: |
| return skill_text |
|
|
| normalized = str(skill_text).strip() |
| normalized_lower = normalized.lower() |
|
|
| uppercase_map = { |
| "aws": "AWS", |
| "gcp": "GCP", |
| "sql": "SQL", |
| "html": "HTML", |
| "css": "CSS", |
| "php": "PHP", |
| "api": "API", |
| "erp": "ERP", |
| "ci/cd": "CI/CD", |
| "devops": "DevOps", |
| "node.js": "Node.js", |
| "javascript": "JavaScript", |
| "typescript": "TypeScript", |
| "postgresql": "PostgreSQL", |
| "mysql": "MySQL", |
| } |
|
|
| if normalized_lower in uppercase_map: |
| return uppercase_map[normalized_lower] |
|
|
| return normalized.replace('_', ' ').title() |
|
|
| def _get_text_nodes(self): |
| """Return all document text nodes, including text boxes/shapes.""" |
| return self.doc._element.xpath('.//w:t') |
|
|
| def _replace_text_between_markers(self, nodes, start_idx, end_marker_text, replacement_lines): |
| """Replace text nodes between a marker and the next section marker.""" |
| i = start_idx + 1 |
| editable_indices = [] |
| while i < len(nodes): |
| text = (nodes[i].text or '').strip() |
| if text == end_marker_text: |
| break |
| if text: |
| editable_indices.append(i) |
| i += 1 |
|
|
| if not editable_indices: |
| return False |
|
|
| for idx, line in zip(editable_indices, replacement_lines): |
| nodes[idx].text = line |
|
|
| for idx in editable_indices[len(replacement_lines):]: |
| nodes[idx].text = "" |
|
|
| return True |
|
|
| def _update_designed_template_skills(self, matched_categories): |
| """Update Skill Highlights inside designed templates (text-box based CVs).""" |
| nodes = self._get_text_nodes() |
| texts = [(n.text or '').strip() for n in nodes] |
|
|
| |
| highlights = [] |
| seen = set() |
| for _, skills in matched_categories.items(): |
| for skill in skills or []: |
| formatted = self._format_skill(skill) |
| key = formatted.lower() |
| if key not in seen: |
| seen.add(key) |
| highlights.append(formatted) |
| highlights = highlights[:6] |
|
|
| if not highlights: |
| return False |
|
|
| changed = False |
| for idx, text in enumerate(texts): |
| if text == "Skill Highlights": |
| if self._replace_text_between_markers(nodes, idx, "Languages", highlights): |
| changed = True |
|
|
| return changed |
|
|
| def _update_designed_template_summary(self, summary_text): |
| """Update existing top summary text blocks inside designed templates.""" |
| if not summary_text or not str(summary_text).strip(): |
| return False |
|
|
| nodes = self._get_text_nodes() |
| summary = summary_text.strip() |
|
|
| title_part = summary |
| detail_part = "" |
| marker = " professional" |
| marker_idx = summary.lower().find(marker) |
| if marker_idx > 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 |
| """ |
| |
| 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' |
| ] |
| |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| 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): |
| |
| if i > 0: |
| logger.info(f"Found potential skills bullet point at paragraph {i}: '{para.text}'") |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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']) |
|
|
| |
| 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 |
|
|
| |
| if self._update_designed_template_skills(matched_categories): |
| logger.info("Updated skills in designed template text boxes") |
| return True |
| |
| |
| skills_idx, skills_para = self.find_skills_section() |
| |
| if skills_idx is None: |
| |
| 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: |
| |
| for run in skills_para.runs: |
| run.bold = True |
| run.font.size = Pt(14) |
| skills_idx = len(self.doc.paragraphs) - 1 |
| |
| |
| |
| 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 next_section_idx is None: |
| next_section_idx = len(self.doc.paragraphs) |
| |
| |
| |
| |
| 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) |
| |
| |
| anchor_para = skills_para |
| for category, skills in matched_categories.items(): |
| if skills: |
| |
| 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 |
|
|
| |
| |
| 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: |
| |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) |
| |
| |
| 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_directory_exists(output_dir) |
| |
| |
| 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 |
| |
| |
| self.doc = Document(self.cv_template_path) |
| |
| |
| if self.update_skills_section(matched_categories): |
| |
| 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) |
| |
| |
| 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: |
| |
| jobs_df = pd.read_excel(jobs_file) |
| |
| if jobs_df.empty: |
| logger.warning(f"No jobs found in {jobs_file}") |
| return [] |
| |
| |
| analyzer = CVAnalyzer() |
| processed_df = analyzer.process_job_descriptions(jobs_df) |
| |
| |
| 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. |
| """ |
| |
| import os |
| |
| |
| jobs_file = input("Enter path to jobs Excel file: ") |
| if not os.path.exists(jobs_file): |
| print(f"File not found: {jobs_file}") |
| return |
| |
| |
| 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 |
| |
| |
| output_dir = os.path.join(os.getcwd(), "job_apply_ai", "outputs", "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() |