| """ |
| 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 |
|
|
| |
| 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 |
| """ |
| |
| cv_content = self.cv_parser.get_all_text() |
| |
| |
| 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: |
| |
| analysis = self.analyze_job_description(job_description) |
| |
| if "error" in analysis: |
| raise ValueError(f"Error analyzing job description: {analysis['error']}") |
| |
| |
| suggestions = {} |
| if "raw_response" in analysis: |
| try: |
| |
| suggestions = json.loads(analysis["raw_response"]) |
| except json.JSONDecodeError: |
| |
| suggestions = self._extract_suggestions_from_text(analysis["raw_response"]) |
| else: |
| suggestions = analysis |
| |
| |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) |
| |
| |
| new_doc = Document(self.cv_path) |
| |
| |
| if "profile_summary" in suggestions and suggestions["profile_summary"]: |
| self._update_profile_summary(new_doc, suggestions["profile_summary"]) |
| |
| |
| if "skills" in suggestions and suggestions["skills"]: |
| self._update_skills(new_doc, suggestions["skills"]) |
| |
| |
| if "experience_highlights" in suggestions and suggestions["experience_highlights"]: |
| self._update_experience(new_doc, suggestions["experience_highlights"]) |
| |
| |
| 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: |
| |
| cover_letter_content = self.cover_letter_parser.get_all_text() |
| cv_content = self.cv_parser.get_all_text() |
| |
| |
| 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) |
| |
| |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) |
| |
| |
| new_doc = Document(self.cover_letter_path) |
| |
| |
| self._update_cover_letter_body(new_doc, tailored_body) |
| |
| |
| 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": [] |
| } |
| |
| |
| profile_match = re.search(r'"profile_summary":\s*"([^"]+)"', text) |
| if profile_match: |
| suggestions["profile_summary"] = profile_match.group(1) |
| |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| 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 |
| """ |
| |
| 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: |
| |
| 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) |
| |
| |
| if profile_index + 1 < len(doc.paragraphs): |
| |
| for i in range(profile_index + 1, next_section_index): |
| doc.paragraphs[i].clear() |
| |
| |
| 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 |
| """ |
| |
| 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: |
| |
| 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) |
| |
| |
| if skills_index + 1 < len(doc.paragraphs): |
| |
| for i in range(skills_index + 1, next_section_index): |
| doc.paragraphs[i].clear() |
| |
| |
| 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 |
| """ |
| |
| |
| |
| |
| |
| 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: |
| |
| if experience_index + 1 < len(doc.paragraphs): |
| highlight_note = "Key highlights relevant to this position: " + "; ".join(new_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 |
| """ |
| |
| body_start = None |
| body_end = None |
| |
| |
| format_info = [] |
| |
| |
| for i, para in enumerate(doc.paragraphs): |
| text = para.text.strip() |
| |
| |
| 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] |
| }) |
| |
| |
| if not text: |
| continue |
| |
| |
| 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 |
| |
| |
| 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 body_start is None: |
| |
| 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: |
| |
| 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 |
| |
| |
| new_paragraphs = [p.strip() for p in new_body.strip().split("\n\n") if p.strip()] |
| |
| |
| before_body = [para.text for para in doc.paragraphs[:body_start]] |
| after_body = [para.text for para in doc.paragraphs[body_end:]] |
| |
| |
| for _ in range(len(doc.paragraphs)): |
| if len(doc.paragraphs) > 0: |
| p = doc.paragraphs[0]._element |
| p.getparent().remove(p) |
| |
| |
| |
| 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]) |
| |
| |
| for text in new_paragraphs: |
| p = doc.add_paragraph(text) |
| |
| p.style = 'Normal' |
| p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.LEFT |
| |
| |
| 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 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: |
| |
| pass |
|
|