""" Document updater module for tailoring CV and cover letter based on job descriptions. This module combines document parsing and OpenAI integration to create tailored documents. """ import os import json import docx from docx import Document from typing import Dict, List, Tuple, Any, Optional import sys import re from datetime import datetime # Add the project root to the path to import our modules sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from parsers.document_parser import CVParser, CoverLetterParser from utils.openai_integration import OpenAIIntegration class DocumentUpdater: """Class for updating CV and cover letter documents based on job descriptions.""" def __init__(self, cv_path: str, cover_letter_path: str, openai_integration: OpenAIIntegration): """Initialize document updater. Args: cv_path: Path to the CV document cover_letter_path: Path to the cover letter document openai_integration: Initialized OpenAIIntegration instance """ if not os.path.exists(cv_path): raise FileNotFoundError(f"CV file not found: {cv_path}") if not os.path.exists(cover_letter_path): raise FileNotFoundError(f"Cover letter file not found: {cover_letter_path}") self.cv_parser = CVParser(cv_path) self.cover_letter_parser = CoverLetterParser(cover_letter_path) self.openai_integration = openai_integration self.cv_path = cv_path self.cover_letter_path = cover_letter_path def analyze_job_description(self, job_description: str) -> Dict[str, Any]: """Analyze job description and get tailoring suggestions. Args: job_description: Text of the job description Returns: Dictionary with analysis results and suggestions """ # Get current CV content cv_content = self.cv_parser.get_all_text() # Use OpenAI to analyze job description and suggest modifications analysis_result = self.openai_integration.analyze_job_description(job_description, cv_content) return analysis_result def update_cv(self, job_description: str, output_path: str) -> str: """Create a tailored CV based on job description. Args: job_description: Text of the job description output_path: Path where the tailored CV should be saved Returns: Path to the tailored CV document """ try: # Analyze job description analysis = self.analyze_job_description(job_description) if "error" in analysis: raise ValueError(f"Error analyzing job description: {analysis['error']}") # Parse the raw response if it exists suggestions = {} if "raw_response" in analysis: try: # Try to parse as JSON suggestions = json.loads(analysis["raw_response"]) except json.JSONDecodeError: # If not valid JSON, extract using regex suggestions = self._extract_suggestions_from_text(analysis["raw_response"]) else: suggestions = analysis # Already parsed JSON # Create output directory if it doesn't exist os.makedirs(os.path.dirname(output_path), exist_ok=True) # Create a new document by copying the original new_doc = Document(self.cv_path) # Update profile summary if suggested if "profile_summary" in suggestions and suggestions["profile_summary"]: self._update_profile_summary(new_doc, suggestions["profile_summary"]) # Update skills if suggested if "skills" in suggestions and suggestions["skills"]: self._update_skills(new_doc, suggestions["skills"]) # Update experience highlights if suggested if "experience_highlights" in suggestions and suggestions["experience_highlights"]: self._update_experience(new_doc, suggestions["experience_highlights"]) # Save the updated document new_doc.save(output_path) return output_path except Exception as e: raise Exception(f"Error updating CV: {str(e)}") def update_cover_letter(self, job_description: str, output_path: str) -> str: """Create a tailored cover letter based on job description. Args: job_description: Text of the job description output_path: Path where the tailored cover letter should be saved Returns: Path to the tailored cover letter document """ try: # Get current cover letter and CV content cover_letter_content = self.cover_letter_parser.get_all_text() cv_content = self.cv_parser.get_all_text() # Use OpenAI to generate tailored cover letter body tailored_body = self.openai_integration.tailor_cover_letter( job_description, cover_letter_content, cv_content ) if tailored_body.startswith("Error generating cover letter:"): raise ValueError(tailored_body) # Create output directory if it doesn't exist os.makedirs(os.path.dirname(output_path), exist_ok=True) # Create a new document by copying the original new_doc = Document(self.cover_letter_path) # Update the body of the cover letter self._update_cover_letter_body(new_doc, tailored_body) # Save the updated document new_doc.save(output_path) return output_path except Exception as e: raise Exception(f"Error updating cover letter: {str(e)}") def _extract_suggestions_from_text(self, text: str) -> Dict[str, Any]: """Extract suggestions from text when JSON parsing fails. Args: text: Raw text response from OpenAI Returns: Dictionary with extracted suggestions """ suggestions = { "profile_summary": "", "skills": [], "experience_highlights": [], "keywords_to_emphasize": [] } # Extract profile summary profile_match = re.search(r'"profile_summary":\s*"([^"]+)"', text) if profile_match: suggestions["profile_summary"] = profile_match.group(1) # Extract skills skills_match = re.search(r'"skills":\s*\[(.*?)\]', text, re.DOTALL) if skills_match: skills_text = skills_match.group(1) skills = re.findall(r'"([^"]+)"', skills_text) suggestions["skills"] = skills # Extract experience highlights exp_match = re.search(r'"experience_highlights":\s*\[(.*?)\]', text, re.DOTALL) if exp_match: exp_text = exp_match.group(1) experiences = re.findall(r'"([^"]+)"', exp_text) suggestions["experience_highlights"] = experiences # Extract keywords keywords_match = re.search(r'"keywords_to_emphasize":\s*\[(.*?)\]', text, re.DOTALL) if keywords_match: keywords_text = keywords_match.group(1) keywords = re.findall(r'"([^"]+)"', keywords_text) suggestions["keywords_to_emphasize"] = keywords return suggestions def _update_profile_summary(self, doc: Document, new_summary: str) -> None: """Update the profile summary in the document. Args: doc: Document object to update new_summary: New profile summary text """ # Find the PROFILE section profile_index = None for i, para in enumerate(doc.paragraphs): if para.text.strip() == "PROFILE": profile_index = i break if profile_index is not None: # Clear existing profile paragraphs next_section_index = None for i in range(profile_index + 1, len(doc.paragraphs)): if doc.paragraphs[i].text.strip().isupper() and len(doc.paragraphs[i].text.strip()) < 30: next_section_index = i break if next_section_index is None: next_section_index = len(doc.paragraphs) # Update the first paragraph after PROFILE if profile_index + 1 < len(doc.paragraphs): # Clear existing text for i in range(profile_index + 1, next_section_index): doc.paragraphs[i].clear() # Add new summary to the first paragraph after PROFILE doc.paragraphs[profile_index + 1].add_run(new_summary) def _update_skills(self, doc: Document, new_skills: List[str]) -> None: """Update the skills section in the document. Args: doc: Document object to update new_skills: List of new skills to include """ # Find the SKILLS section (could be SKILLS or TECHNICAL SKILLS) skills_index = None for i, para in enumerate(doc.paragraphs): if para.text.strip() in ["SKILLS", "TECHNICAL SKILLS"]: skills_index = i break if skills_index is not None: # Clear existing skills paragraphs next_section_index = None for i in range(skills_index + 1, len(doc.paragraphs)): if doc.paragraphs[i].text.strip().isupper() and len(doc.paragraphs[i].text.strip()) < 30: next_section_index = i break if next_section_index is None: next_section_index = len(doc.paragraphs) # Update the first paragraph after SKILLS if skills_index + 1 < len(doc.paragraphs): # Clear existing text for i in range(skills_index + 1, next_section_index): doc.paragraphs[i].clear() # Add new skills to the first paragraph after SKILLS skills_text = ", ".join(new_skills) doc.paragraphs[skills_index + 1].add_run(skills_text) def _update_experience(self, doc: Document, new_highlights: List[str]) -> None: """Update the experience section in the document to emphasize certain points. Args: doc: Document object to update new_highlights: List of experience highlights to emphasize """ # This is a simplified implementation # In a real implementation, you would need to carefully modify the experience section # while preserving formatting and structure # Find the EXPERIENCE section (could be EXPERIENCE or WORK EXPERIENCE) experience_index = None for i, para in enumerate(doc.paragraphs): if para.text.strip() in ["EXPERIENCE", "WORK EXPERIENCE"]: experience_index = i break if experience_index is not None: # Add a note about the highlights at the beginning of the section if experience_index + 1 < len(doc.paragraphs): highlight_note = "Key highlights relevant to this position: " + "; ".join(new_highlights) # Insert a new paragraph for the highlights p = doc.paragraphs[experience_index] run = p.add_run() run.add_break() run.add_text(highlight_note) def _update_cover_letter_body(self, doc: Document, new_body: str) -> None: """Update the body of the cover letter with new content while preserving formatting. Args: doc: Document object to update new_body: New body text for the cover letter """ # Find the start of the body (after greeting) and end (before closing) body_start = None body_end = None # Store paragraph formatting for reuse format_info = [] # First pass: identify body section and store formatting for i, para in enumerate(doc.paragraphs): text = para.text.strip() # Store formatting information for each paragraph format_info.append({ 'style': para.style, 'alignment': para.alignment, 'runs': [(run.bold, run.italic, run.underline, run.font.name, run.font.size) for run in para.runs] }) # Skip empty paragraphs but preserve them if not text: continue # Look for greeting more carefully if any(text.startswith(greeting) for greeting in [ "Dear ", "To ", "Hi ", "Hello ", "Dear Sir", "Dear Madam", "Dear Hiring", "Dear Recruitment", "Dear HR" ]): body_start = i + 1 # Look for closing more carefully elif any(text.lower().startswith(closing.lower()) for closing in [ "Sincerely", "Best regards", "Kind regards", "Yours sincerely", "Best", "Regards", "Thank you", "Yours faithfully", "Yours truly" ]): body_end = i break # If we couldn't find the body section, try to make a best guess if body_start is None: # Look for the first non-header paragraph for i, para in enumerate(doc.paragraphs): if not any(para.text.strip().lower().startswith(header) for header in [ "name:", "address:", "phone:", "email:", "date:" ]): body_start = i break if body_start is None: body_start = 0 if body_end is None: # Look for signature block for i in range(len(doc.paragraphs) - 1, -1, -1): text = doc.paragraphs[i].text.strip().lower() if text and not any(text.startswith(sig) for sig in [ "phone", "email", "address", "mobile", "tel", "website" ]): body_end = i break if body_end is None or body_end <= body_start: body_end = len(doc.paragraphs) - 1 # Split new body text into paragraphs new_paragraphs = [p.strip() for p in new_body.strip().split("\n\n") if p.strip()] # Store paragraphs that come before and after the body before_body = [para.text for para in doc.paragraphs[:body_start]] after_body = [para.text for para in doc.paragraphs[body_end:]] # Clear the document for _ in range(len(doc.paragraphs)): if len(doc.paragraphs) > 0: # Check if there are any paragraphs left p = doc.paragraphs[0]._element p.getparent().remove(p) # Rebuild the document # Add paragraphs before body for i, text in enumerate(before_body): p = doc.add_paragraph(text) if i < len(format_info): self._apply_paragraph_format(p, format_info[i]) # Add new body paragraphs for text in new_paragraphs: p = doc.add_paragraph(text) # Apply a default professional format p.style = 'Normal' p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.LEFT # Add paragraphs after body for i, text in enumerate(after_body): p = doc.add_paragraph(text) if body_end + i < len(format_info): self._apply_paragraph_format(p, format_info[body_end + i]) def _apply_paragraph_format(self, paragraph, format_info): """Apply stored formatting to a paragraph. Args: paragraph: The paragraph to format format_info: Dictionary containing formatting information """ try: paragraph.style = format_info['style'] paragraph.alignment = format_info['alignment'] # If there's text in the paragraph, apply run formatting if paragraph.runs and format_info['runs']: for run, (bold, italic, underline, font_name, font_size) in zip( paragraph.runs, format_info['runs'] ): run.bold = bold run.italic = italic run.underline = underline if font_name: run.font.name = font_name if font_size: run.font.size = font_size except Exception: # If any formatting fails, keep going with what we can apply pass