Spaces:
Runtime error
Runtime error
| import base64 | |
| import sys | |
| from pathlib import Path | |
| import traceback | |
| from typing import List, Optional, Tuple, Dict | |
| import click | |
| import inquirer | |
| import yaml | |
| from selenium import webdriver | |
| from selenium.common.exceptions import WebDriverException | |
| from selenium.webdriver.chrome.service import Service as ChromeService | |
| from webdriver_manager.chrome import ChromeDriverManager | |
| import re | |
| from src.libs.resume_and_cover_builder import ResumeFacade, ResumeGenerator, StyleManager | |
| from src.resume_schemas.job_application_profile import JobApplicationProfile | |
| from src.resume_schemas.resume import Resume | |
| from src.logging import logger | |
| from src.utils.chrome_utils import init_browser | |
| from src.utils.constants import ( | |
| PLAIN_TEXT_RESUME_YAML, | |
| SECRETS_YAML, | |
| WORK_PREFERENCES_YAML, | |
| ) | |
| # from ai_hawk.bot_facade import AIHawkBotFacade | |
| # from ai_hawk.job_manager import AIHawkJobManager | |
| # from ai_hawk.llm.llm_manager import GPTAnswerer | |
| class ConfigError(Exception): | |
| """Custom exception for configuration-related errors.""" | |
| pass | |
| class ConfigValidator: | |
| """Validates configuration and secrets YAML files.""" | |
| EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") | |
| REQUIRED_CONFIG_KEYS = { | |
| "remote": bool, | |
| "experience_level": dict, | |
| "job_types": dict, | |
| "date": dict, | |
| "positions": list, | |
| "locations": list, | |
| "location_blacklist": list, | |
| "distance": int, | |
| "company_blacklist": list, | |
| "title_blacklist": list, | |
| } | |
| EXPERIENCE_LEVELS = [ | |
| "internship", | |
| "entry", | |
| "associate", | |
| "mid_senior_level", | |
| "director", | |
| "executive", | |
| ] | |
| JOB_TYPES = [ | |
| "full_time", | |
| "contract", | |
| "part_time", | |
| "temporary", | |
| "internship", | |
| "other", | |
| "volunteer", | |
| ] | |
| DATE_FILTERS = ["all_time", "month", "week", "24_hours"] | |
| APPROVED_DISTANCES = {0, 5, 10, 25, 50, 100} | |
| def validate_email(email: str) -> bool: | |
| """Validate the format of an email address.""" | |
| return bool(ConfigValidator.EMAIL_REGEX.match(email)) | |
| def load_yaml(yaml_path: Path) -> dict: | |
| """Load and parse a YAML file.""" | |
| try: | |
| with open(yaml_path, "r") as stream: | |
| return yaml.safe_load(stream) | |
| except yaml.YAMLError as exc: | |
| raise ConfigError(f"Error reading YAML file {yaml_path}: {exc}") | |
| except FileNotFoundError: | |
| raise ConfigError(f"YAML file not found: {yaml_path}") | |
| def validate_config(cls, config_yaml_path: Path) -> dict: | |
| """Validate the main configuration YAML file.""" | |
| parameters = cls.load_yaml(config_yaml_path) | |
| # Check for required keys and their types | |
| for key, expected_type in cls.REQUIRED_CONFIG_KEYS.items(): | |
| if key not in parameters: | |
| if key in ["company_blacklist", "title_blacklist", "location_blacklist"]: | |
| parameters[key] = [] | |
| else: | |
| raise ConfigError(f"Missing required key '{key}' in {config_yaml_path}") | |
| elif not isinstance(parameters[key], expected_type): | |
| if key in ["company_blacklist", "title_blacklist", "location_blacklist"] and parameters[key] is None: | |
| parameters[key] = [] | |
| else: | |
| raise ConfigError( | |
| f"Invalid type for key '{key}' in {config_yaml_path}. Expected {expected_type.__name__}." | |
| ) | |
| cls._validate_experience_levels(parameters["experience_level"], config_yaml_path) | |
| cls._validate_job_types(parameters["job_types"], config_yaml_path) | |
| cls._validate_date_filters(parameters["date"], config_yaml_path) | |
| cls._validate_list_of_strings(parameters, ["positions", "locations"], config_yaml_path) | |
| cls._validate_distance(parameters["distance"], config_yaml_path) | |
| cls._validate_blacklists(parameters, config_yaml_path) | |
| return parameters | |
| def _validate_experience_levels(cls, experience_levels: dict, config_path: Path): | |
| """Ensure experience levels are booleans.""" | |
| for level in cls.EXPERIENCE_LEVELS: | |
| if not isinstance(experience_levels.get(level), bool): | |
| raise ConfigError( | |
| f"Experience level '{level}' must be a boolean in {config_path}" | |
| ) | |
| def _validate_job_types(cls, job_types: dict, config_path: Path): | |
| """Ensure job types are booleans.""" | |
| for job_type in cls.JOB_TYPES: | |
| if not isinstance(job_types.get(job_type), bool): | |
| raise ConfigError( | |
| f"Job type '{job_type}' must be a boolean in {config_path}" | |
| ) | |
| def _validate_date_filters(cls, date_filters: dict, config_path: Path): | |
| """Ensure date filters are booleans.""" | |
| for date_filter in cls.DATE_FILTERS: | |
| if not isinstance(date_filters.get(date_filter), bool): | |
| raise ConfigError( | |
| f"Date filter '{date_filter}' must be a boolean in {config_path}" | |
| ) | |
| def _validate_list_of_strings(cls, parameters: dict, keys: list, config_path: Path): | |
| """Ensure specified keys are lists of strings.""" | |
| for key in keys: | |
| if not all(isinstance(item, str) for item in parameters[key]): | |
| raise ConfigError( | |
| f"'{key}' must be a list of strings in {config_path}" | |
| ) | |
| def _validate_distance(cls, distance: int, config_path: Path): | |
| """Validate the distance value.""" | |
| if distance not in cls.APPROVED_DISTANCES: | |
| raise ConfigError( | |
| f"Invalid distance value '{distance}' in {config_path}. Must be one of: {cls.APPROVED_DISTANCES}" | |
| ) | |
| def _validate_blacklists(cls, parameters: dict, config_path: Path): | |
| """Ensure blacklists are lists.""" | |
| for blacklist in ["company_blacklist", "title_blacklist", "location_blacklist"]: | |
| if not isinstance(parameters.get(blacklist), list): | |
| raise ConfigError( | |
| f"'{blacklist}' must be a list in {config_path}" | |
| ) | |
| if parameters[blacklist] is None: | |
| parameters[blacklist] = [] | |
| def validate_secrets(secrets_yaml_path: Path) -> str: | |
| """Validate the secrets YAML file and retrieve the LLM API key.""" | |
| secrets = ConfigValidator.load_yaml(secrets_yaml_path) | |
| mandatory_secrets = ["llm_api_key"] | |
| for secret in mandatory_secrets: | |
| if secret not in secrets: | |
| raise ConfigError(f"Missing secret '{secret}' in {secrets_yaml_path}") | |
| if not secrets[secret]: | |
| raise ConfigError(f"Secret '{secret}' cannot be empty in {secrets_yaml_path}") | |
| return secrets["llm_api_key"] | |
| class FileManager: | |
| """Handles file system operations and validations.""" | |
| REQUIRED_FILES = [SECRETS_YAML, WORK_PREFERENCES_YAML, PLAIN_TEXT_RESUME_YAML] | |
| def validate_data_folder(app_data_folder: Path) -> Tuple[Path, Path, Path, Path]: | |
| """Validate the existence of the data folder and required files.""" | |
| if not app_data_folder.is_dir(): | |
| raise FileNotFoundError(f"Data folder not found: {app_data_folder}") | |
| missing_files = [file for file in FileManager.REQUIRED_FILES if not (app_data_folder / file).exists()] | |
| if missing_files: | |
| raise FileNotFoundError(f"Missing files in data folder: {', '.join(missing_files)}") | |
| output_folder = app_data_folder / "output" | |
| output_folder.mkdir(exist_ok=True) | |
| return ( | |
| app_data_folder / SECRETS_YAML, | |
| app_data_folder / WORK_PREFERENCES_YAML, | |
| app_data_folder / PLAIN_TEXT_RESUME_YAML, | |
| output_folder, | |
| ) | |
| def get_uploads(plain_text_resume_file: Path) -> Dict[str, Path]: | |
| """Convert resume file paths to a dictionary.""" | |
| if not plain_text_resume_file.exists(): | |
| raise FileNotFoundError(f"Plain text resume file not found: {plain_text_resume_file}") | |
| uploads = {"plainTextResume": plain_text_resume_file} | |
| return uploads | |
| def create_cover_letter(parameters: dict, llm_api_key: str): | |
| """ | |
| Logic to create a CV. | |
| """ | |
| try: | |
| logger.info("Generating a CV based on provided parameters.") | |
| # Carica il resume in testo semplice | |
| with open(parameters["uploads"]["plainTextResume"], "r", encoding="utf-8") as file: | |
| plain_text_resume = file.read() | |
| style_manager = StyleManager() | |
| available_styles = style_manager.get_styles() | |
| if not available_styles: | |
| logger.warning("No styles available. Proceeding without style selection.") | |
| else: | |
| # Present style choices to the user | |
| choices = style_manager.format_choices(available_styles) | |
| questions = [ | |
| inquirer.List( | |
| "style", | |
| message="Select a style for the resume:", | |
| choices=choices, | |
| ) | |
| ] | |
| style_answer = inquirer.prompt(questions) | |
| if style_answer and "style" in style_answer: | |
| selected_choice = style_answer["style"] | |
| for style_name, (file_name, author_link) in available_styles.items(): | |
| if selected_choice.startswith(style_name): | |
| style_manager.set_selected_style(style_name) | |
| logger.info(f"Selected style: {style_name}") | |
| break | |
| else: | |
| logger.warning("No style selected. Proceeding with default style.") | |
| questions = [ | |
| inquirer.Text('job_url', message="Please enter the URL of the job description:") | |
| ] | |
| answers = inquirer.prompt(questions) | |
| job_url = answers.get('job_url') | |
| resume_generator = ResumeGenerator() | |
| resume_object = Resume(plain_text_resume) | |
| driver = init_browser() | |
| resume_generator.set_resume_object(resume_object) | |
| resume_facade = ResumeFacade( | |
| api_key=llm_api_key, | |
| style_manager=style_manager, | |
| resume_generator=resume_generator, | |
| resume_object=resume_object, | |
| output_path=Path("data_folder/output"), | |
| ) | |
| resume_facade.set_driver(driver) | |
| resume_facade.link_to_job(job_url) | |
| result_base64, suggested_name = resume_facade.create_cover_letter() | |
| # Decodifica Base64 in dati binari | |
| try: | |
| pdf_data = base64.b64decode(result_base64) | |
| except base64.binascii.Error as e: | |
| logger.error("Error decoding Base64: %s", e) | |
| raise | |
| # Definisci il percorso della cartella di output utilizzando `suggested_name` | |
| output_dir = Path(parameters["outputFileDirectory"]) / suggested_name | |
| # Crea la cartella se non esiste | |
| try: | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| logger.info(f"Cartella di output creata o già esistente: {output_dir}") | |
| except IOError as e: | |
| logger.error("Error creating output directory: %s", e) | |
| raise | |
| output_path = output_dir / "cover_letter_tailored.pdf" | |
| try: | |
| with open(output_path, "wb") as file: | |
| file.write(pdf_data) | |
| logger.info(f"CV salvato in: {output_path}") | |
| except IOError as e: | |
| logger.error("Error writing file: %s", e) | |
| raise | |
| except Exception as e: | |
| logger.exception(f"An error occurred while creating the CV: {e}") | |
| raise | |
| def create_resume_pdf_job_tailored(parameters: dict, llm_api_key: str): | |
| """ | |
| Logic to create a CV. | |
| """ | |
| try: | |
| logger.info("Generating a CV based on provided parameters.") | |
| # Carica il resume in testo semplice | |
| with open(parameters["uploads"]["plainTextResume"], "r", encoding="utf-8") as file: | |
| plain_text_resume = file.read() | |
| style_manager = StyleManager() | |
| available_styles = style_manager.get_styles() | |
| if not available_styles: | |
| logger.warning("No styles available. Proceeding without style selection.") | |
| else: | |
| # Present style choices to the user | |
| choices = style_manager.format_choices(available_styles) | |
| questions = [ | |
| inquirer.List( | |
| "style", | |
| message="Select a style for the resume:", | |
| choices=choices, | |
| ) | |
| ] | |
| style_answer = inquirer.prompt(questions) | |
| if style_answer and "style" in style_answer: | |
| selected_choice = style_answer["style"] | |
| for style_name, (file_name, author_link) in available_styles.items(): | |
| if selected_choice.startswith(style_name): | |
| style_manager.set_selected_style(style_name) | |
| logger.info(f"Selected style: {style_name}") | |
| break | |
| else: | |
| logger.warning("No style selected. Proceeding with default style.") | |
| questions = [inquirer.Text('job_url', message="Please enter the URL of the job description:")] | |
| answers = inquirer.prompt(questions) | |
| job_url = answers.get('job_url') | |
| resume_generator = ResumeGenerator() | |
| resume_object = Resume(plain_text_resume) | |
| driver = init_browser() | |
| resume_generator.set_resume_object(resume_object) | |
| resume_facade = ResumeFacade( | |
| api_key=llm_api_key, | |
| style_manager=style_manager, | |
| resume_generator=resume_generator, | |
| resume_object=resume_object, | |
| output_path=Path("data_folder/output"), | |
| ) | |
| resume_facade.set_driver(driver) | |
| resume_facade.link_to_job(job_url) | |
| result_base64, suggested_name = resume_facade.create_resume_pdf_job_tailored() | |
| # Decodifica Base64 in dati binari | |
| try: | |
| pdf_data = base64.b64decode(result_base64) | |
| except base64.binascii.Error as e: | |
| logger.error("Error decoding Base64: %s", e) | |
| raise | |
| # Definisci il percorso della cartella di output utilizzando `suggested_name` | |
| output_dir = Path(parameters["outputFileDirectory"]) / suggested_name | |
| # Crea la cartella se non esiste | |
| try: | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| logger.info(f"Cartella di output creata o già esistente: {output_dir}") | |
| except IOError as e: | |
| logger.error("Error creating output directory: %s", e) | |
| raise | |
| output_path = output_dir / "resume_tailored.pdf" | |
| try: | |
| with open(output_path, "wb") as file: | |
| file.write(pdf_data) | |
| logger.info(f"CV salvato in: {output_path}") | |
| except IOError as e: | |
| logger.error("Error writing file: %s", e) | |
| raise | |
| except Exception as e: | |
| logger.exception(f"An error occurred while creating the CV: {e}") | |
| raise | |
| def create_resume_pdf(parameters: dict, llm_api_key: str): | |
| """ | |
| Logic to create a CV. | |
| """ | |
| try: | |
| logger.info("Generating a CV based on provided parameters.") | |
| # Load the plain text resume | |
| with open(parameters["uploads"]["plainTextResume"], "r", encoding="utf-8") as file: | |
| plain_text_resume = file.read() | |
| # Initialize StyleManager | |
| style_manager = StyleManager() | |
| available_styles = style_manager.get_styles() | |
| if not available_styles: | |
| logger.warning("No styles available. Proceeding without style selection.") | |
| else: | |
| # Present style choices to the user | |
| choices = style_manager.format_choices(available_styles) | |
| questions = [ | |
| inquirer.List( | |
| "style", | |
| message="Select a style for the resume:", | |
| choices=choices, | |
| ) | |
| ] | |
| style_answer = inquirer.prompt(questions) | |
| if style_answer and "style" in style_answer: | |
| selected_choice = style_answer["style"] | |
| for style_name, (file_name, author_link) in available_styles.items(): | |
| if selected_choice.startswith(style_name): | |
| style_manager.set_selected_style(style_name) | |
| logger.info(f"Selected style: {style_name}") | |
| break | |
| else: | |
| logger.warning("No style selected. Proceeding with default style.") | |
| # Initialize the Resume Generator | |
| resume_generator = ResumeGenerator() | |
| resume_object = Resume(plain_text_resume) | |
| driver = init_browser() | |
| resume_generator.set_resume_object(resume_object) | |
| # Create the ResumeFacade | |
| resume_facade = ResumeFacade( | |
| api_key=llm_api_key, | |
| style_manager=style_manager, | |
| resume_generator=resume_generator, | |
| resume_object=resume_object, | |
| output_path=Path("data_folder/output"), | |
| ) | |
| resume_facade.set_driver(driver) | |
| result_base64 = resume_facade.create_resume_pdf() | |
| # Decode Base64 to binary data | |
| try: | |
| pdf_data = base64.b64decode(result_base64) | |
| except base64.binascii.Error as e: | |
| logger.error("Error decoding Base64: %s", e) | |
| raise | |
| # Define the output directory using `suggested_name` | |
| output_dir = Path(parameters["outputFileDirectory"]) | |
| # Write the PDF file | |
| output_path = output_dir / "resume_base.pdf" | |
| try: | |
| with open(output_path, "wb") as file: | |
| file.write(pdf_data) | |
| logger.info(f"Resume saved at: {output_path}") | |
| except IOError as e: | |
| logger.error("Error writing file: %s", e) | |
| raise | |
| except Exception as e: | |
| logger.exception(f"An error occurred while creating the CV: {e}") | |
| raise | |
| def handle_inquiries(selected_actions: List[str], parameters: dict, llm_api_key: str): | |
| """ | |
| Decide which function to call based on the selected user actions. | |
| :param selected_actions: List of actions selected by the user. | |
| :param parameters: Configuration parameters dictionary. | |
| :param llm_api_key: API key for the language model. | |
| """ | |
| try: | |
| if selected_actions: | |
| if "Generate Resume" == selected_actions: | |
| logger.info("Crafting a standout professional resume...") | |
| create_resume_pdf(parameters, llm_api_key) | |
| if "Generate Resume Tailored for Job Description" == selected_actions: | |
| logger.info("Customizing your resume to enhance your job application...") | |
| create_resume_pdf_job_tailored(parameters, llm_api_key) | |
| if "Generate Tailored Cover Letter for Job Description" == selected_actions: | |
| logger.info("Designing a personalized cover letter to enhance your job application...") | |
| create_cover_letter(parameters, llm_api_key) | |
| else: | |
| logger.warning("No actions selected. Nothing to execute.") | |
| except Exception as e: | |
| logger.exception(f"An error occurred while handling inquiries: {e}") | |
| raise | |
| def prompt_user_action() -> str: | |
| """ | |
| Use inquirer to ask the user which action they want to perform. | |
| :return: Selected action. | |
| """ | |
| try: | |
| questions = [ | |
| inquirer.List( | |
| 'action', | |
| message="Select the action you want to perform:", | |
| choices=[ | |
| "Generate Resume", | |
| "Generate Resume Tailored for Job Description", | |
| "Generate Tailored Cover Letter for Job Description", | |
| ], | |
| ), | |
| ] | |
| answer = inquirer.prompt(questions) | |
| if answer is None: | |
| print("No answer provided. The user may have interrupted.") | |
| return "" | |
| return answer.get('action', "") | |
| except Exception as e: | |
| print(f"An error occurred: {e}") | |
| return "" | |
| def main(): | |
| """Main entry point for the AIHawk Job Application Bot.""" | |
| try: | |
| # Define and validate the data folder | |
| data_folder = Path("data_folder") | |
| secrets_file, config_file, plain_text_resume_file, output_folder = FileManager.validate_data_folder(data_folder) | |
| # Validate configuration and secrets | |
| config = ConfigValidator.validate_config(config_file) | |
| llm_api_key = ConfigValidator.validate_secrets(secrets_file) | |
| # Prepare parameters | |
| config["uploads"] = FileManager.get_uploads(plain_text_resume_file) | |
| config["outputFileDirectory"] = output_folder | |
| # Interactive prompt for user to select actions | |
| selected_actions = prompt_user_action() | |
| # Handle selected actions and execute them | |
| handle_inquiries(selected_actions, config, llm_api_key) | |
| except ConfigError as ce: | |
| logger.error(f"Configuration error: {ce}") | |
| logger.error( | |
| "Refer to the configuration guide for troubleshooting: " | |
| "https://github.com/feder-cr/Auto_Jobs_Applier_AIHawk?tab=readme-ov-file#configuration" | |
| ) | |
| except FileNotFoundError as fnf: | |
| logger.error(f"File not found: {fnf}") | |
| logger.error("Ensure all required files are present in the data folder.") | |
| except RuntimeError as re: | |
| logger.error(f"Runtime error: {re}") | |
| logger.debug(traceback.format_exc()) | |
| except Exception as e: | |
| logger.exception(f"An unexpected error occurred: {e}") | |
| if __name__ == "__main__": | |
| main() | |