jaothan's picture
Update app.py
55cbe76 verified
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}
@staticmethod
def validate_email(email: str) -> bool:
"""Validate the format of an email address."""
return bool(ConfigValidator.EMAIL_REGEX.match(email))
@staticmethod
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}")
@classmethod
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
@classmethod
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}"
)
@classmethod
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}"
)
@classmethod
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}"
)
@classmethod
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}"
)
@classmethod
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}"
)
@classmethod
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] = []
@staticmethod
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]
@staticmethod
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,
)
@staticmethod
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()