From 91c7b17e30180e95d37154dca17dc8c8d9a05035 Mon Sep 17 00:00:00 2001 From: Ivan Date: Tue, 5 Nov 2024 16:03:55 +0400 Subject: [PATCH 01/22] Added integration with aiml --- README.md | 2 + src/llm/llm_manager.py | 251 ++++++++++++++++++++++++++--------------- 2 files changed, 162 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index a1e34dd9d..1fdb353ed 100644 --- a/README.md +++ b/README.md @@ -250,12 +250,14 @@ This file defines your job search parameters and bot behavior. Each section cont - ollama: llama2, mistral:v0.3 - claude: any model - gemini: any model + - aiml: any model - `llm_api_url`: - Link of the API endpoint for the LLM model - openai: - ollama: - claude: - gemini: + - aiml: - Note: To run local Ollama, follow the guidelines here: [Guide to Ollama deployment](https://github.com/ollama/ollama) ### 3. plain_text_resume.yaml diff --git a/src/llm/llm_manager.py b/src/llm/llm_manager.py index a96da2710..113193fc9 100644 --- a/src/llm/llm_manager.py +++ b/src/llm/llm_manager.py @@ -6,20 +6,19 @@ from abc import ABC, abstractmethod from datetime import datetime from pathlib import Path -from typing import Dict, List -from typing import Union +from typing import Dict, List, Union import httpx -from Levenshtein import distance from dotenv import load_dotenv from langchain_core.messages import BaseMessage from langchain_core.messages.ai import AIMessage from langchain_core.output_parsers import StrOutputParser from langchain_core.prompt_values import StringPromptValue from langchain_core.prompts import ChatPromptTemplate +from Levenshtein import distance +from loguru import logger import src.strings as strings -from loguru import logger load_dotenv() @@ -33,8 +32,10 @@ def invoke(self, prompt: str) -> str: class OpenAIModel(AIModel): def __init__(self, api_key: str, llm_model: str): from langchain_openai import ChatOpenAI - self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key, - temperature=0.4) + + self.model = ChatOpenAI( + model_name=llm_model, openai_api_key=api_key, temperature=0.4 + ) def invoke(self, prompt: str) -> BaseMessage: logger.debug("Invoking OpenAI API") @@ -42,11 +43,29 @@ def invoke(self, prompt: str) -> BaseMessage: return response +class AIMLModel(AIModel): + def __init__(self, api_key: str, llm_model: str): + from langchain_openai import ChatOpenAI + + self.base_url = "https://api.aimlapi.com/v2" + self.model = ChatOpenAI( + model_name=llm_model, + openai_api_key=api_key, + temperature=0.7, + base_url=self.base_url, + ) + + def invoke(self, prompt: str) -> BaseMessage: + logger.debug("Invoking AIML API") + response = self.model.invoke(prompt) + return response + + class ClaudeModel(AIModel): def __init__(self, api_key: str, llm_model: str): from langchain_anthropic import ChatAnthropic - self.model = ChatAnthropic(model=llm_model, api_key=api_key, - temperature=0.4) + + self.model = ChatAnthropic(model=llm_model, api_key=api_key, temperature=0.4) def invoke(self, prompt: str) -> BaseMessage: response = self.model.invoke(prompt) @@ -68,55 +87,71 @@ def invoke(self, prompt: str) -> BaseMessage: response = self.model.invoke(prompt) return response -#gemini doesn't seem to work because API doesn't rstitute answers for questions that involve answers that are too short + +# gemini doesn't seem to work because API doesn't rstitute answers for questions that involve answers that are too short class GeminiModel(AIModel): - def __init__(self, api_key:str, llm_model: str): - from langchain_google_genai import ChatGoogleGenerativeAI, HarmBlockThreshold, HarmCategory - self.model = ChatGoogleGenerativeAI(model=llm_model, google_api_key=api_key,safety_settings={ - HarmCategory.HARM_CATEGORY_UNSPECIFIED: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_DEROGATORY: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_TOXICITY: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_VIOLENCE: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_SEXUAL: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_MEDICAL: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_DANGEROUS: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE, - HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE - }) + def __init__(self, api_key: str, llm_model: str): + from langchain_google_genai import ( + ChatGoogleGenerativeAI, + HarmBlockThreshold, + HarmCategory, + ) + + self.model = ChatGoogleGenerativeAI( + model=llm_model, + google_api_key=api_key, + safety_settings={ + HarmCategory.HARM_CATEGORY_UNSPECIFIED: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_DEROGATORY: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_TOXICITY: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_VIOLENCE: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_SEXUAL: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_MEDICAL: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_DANGEROUS: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE, + }, + ) def invoke(self, prompt: str) -> BaseMessage: response = self.model.invoke(prompt) return response + class HuggingFaceModel(AIModel): def __init__(self, api_key: str, llm_model: str): - from langchain_huggingface import HuggingFaceEndpoint, ChatHuggingFace - self.model = HuggingFaceEndpoint(repo_id=llm_model, huggingfacehub_api_token=api_key, - temperature=0.4) - self.chatmodel=ChatHuggingFace(llm=self.model) + from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint + + self.model = HuggingFaceEndpoint( + repo_id=llm_model, huggingfacehub_api_token=api_key, temperature=0.4 + ) + self.chatmodel = ChatHuggingFace(llm=self.model) def invoke(self, prompt: str) -> BaseMessage: response = self.chatmodel.invoke(prompt) logger.debug("Invoking Model from Hugging Face API") - print(response,type(response)) + print(response, type(response)) return response + class AIAdapter: def __init__(self, config: dict, api_key: str): self.model = self._create_model(config, api_key) def _create_model(self, config: dict, api_key: str) -> AIModel: - llm_model_type = config['llm_model_type'] - llm_model = config['llm_model'] + llm_model_type = config["llm_model_type"] + llm_model = config["llm_model"] - llm_api_url = config.get('llm_api_url', "") + llm_api_url = config.get("llm_api_url", "") logger.debug(f"Using {llm_model_type} with {llm_model}") if llm_model_type == "openai": return OpenAIModel(api_key, llm_model) + elif llm_model_type == "aiml": + return AIMLModel(api_key, llm_model) elif llm_model_type == "claude": return ClaudeModel(api_key, llm_model) elif llm_model_type == "ollama": @@ -124,7 +159,7 @@ def _create_model(self, config: dict, api_key: str) -> AIModel: elif llm_model_type == "gemini": return GeminiModel(api_key, llm_model) elif llm_model_type == "huggingface": - return HuggingFaceModel(api_key, llm_model) + return HuggingFaceModel(api_key, llm_model) else: raise ValueError(f"Unsupported model type: {llm_model_type}") @@ -133,8 +168,9 @@ def invoke(self, prompt: str) -> str: class LLMLogger: - - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): + def __init__( + self, llm: Union[OpenAIModel, AIMLModel, OllamaModel, ClaudeModel, GeminiModel] + ): self.llm = llm logger.debug(f"LLMLogger successfully initialized with LLM: {llm}") @@ -145,8 +181,7 @@ def log_request(prompts, parsed_reply: Dict[str, Dict]): logger.debug(f"Parsed reply received: {parsed_reply}") try: - calls_log = os.path.join( - Path("data_folder/output"), "open_ai_calls.json") + calls_log = os.path.join(Path("data_folder/output"), "open_ai_calls.json") logger.debug(f"Logging path determined: {calls_log}") except Exception as e: logger.error(f"Error determining the log path: {str(e)}") @@ -174,7 +209,9 @@ def log_request(prompts, parsed_reply: Dict[str, Dict]): f"prompt_{i + 1}": prompt.content for i, prompt in enumerate(prompts.messages) } - logger.debug(f"Prompts converted to dictionary using default method: {prompts}") + logger.debug( + f"Prompts converted to dictionary using default method: {prompts}" + ) except Exception as e: logger.error(f"Error converting prompts using default method: {str(e)}") raise @@ -191,7 +228,9 @@ def log_request(prompts, parsed_reply: Dict[str, Dict]): output_tokens = token_usage["output_tokens"] input_tokens = token_usage["input_tokens"] total_tokens = token_usage["total_tokens"] - logger.debug(f"Token usage - Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}") + logger.debug( + f"Token usage - Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}" + ) except KeyError as e: logger.error(f"KeyError in parsed_reply structure: {str(e)}") raise @@ -206,8 +245,9 @@ def log_request(prompts, parsed_reply: Dict[str, Dict]): try: prompt_price_per_token = 0.00000015 completion_price_per_token = 0.0000006 - total_cost = (input_tokens * prompt_price_per_token) + \ - (output_tokens * completion_price_per_token) + total_cost = (input_tokens * prompt_price_per_token) + ( + output_tokens * completion_price_per_token + ) logger.debug(f"Total cost calculated: {total_cost}") except Exception as e: logger.error(f"Error calculating total cost: {str(e)}") @@ -226,13 +266,14 @@ def log_request(prompts, parsed_reply: Dict[str, Dict]): } logger.debug(f"Log entry created: {log_entry}") except KeyError as e: - logger.error(f"Error creating log entry: missing key {str(e)} in parsed_reply") + logger.error( + f"Error creating log entry: missing key {str(e)} in parsed_reply" + ) raise try: with open(calls_log, "a", encoding="utf-8") as f: - json_string = json.dumps( - log_entry, ensure_ascii=False, indent=4) + json_string = json.dumps(log_entry, ensure_ascii=False, indent=4) f.write(json_string + "\n") logger.debug(f"Log entry written to file: {calls_log}") except Exception as e: @@ -241,7 +282,6 @@ def log_request(prompts, parsed_reply: Dict[str, Dict]): class LoggerChatModel: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): self.llm = llm logger.debug(f"LoggerChatModel successfully initialized with LLM: {llm}") @@ -258,8 +298,7 @@ def __call__(self, messages: List[Dict[str, str]]) -> str: parsed_reply = self.parse_llmresult(reply) logger.debug(f"Parsed LLM reply: {parsed_reply}") - LLMLogger.log_request( - prompts=messages, parsed_reply=parsed_reply) + LLMLogger.log_request(prompts=messages, parsed_reply=parsed_reply) logger.debug("Request successfully logged") return reply @@ -267,32 +306,38 @@ def __call__(self, messages: List[Dict[str, str]]) -> str: except httpx.HTTPStatusError as e: logger.error(f"HTTPStatusError encountered: {str(e)}") if e.response.status_code == 429: - retry_after = e.response.headers.get('retry-after') - retry_after_ms = e.response.headers.get('retry-after-ms') + retry_after = e.response.headers.get("retry-after") + retry_after_ms = e.response.headers.get("retry-after-ms") if retry_after: wait_time = int(retry_after) logger.warning( - f"Rate limit exceeded. Waiting for {wait_time} seconds before retrying (extracted from 'retry-after' header)...") + f"Rate limit exceeded. Waiting for {wait_time} seconds before retrying (extracted from 'retry-after' header)..." + ) time.sleep(wait_time) elif retry_after_ms: wait_time = int(retry_after_ms) / 1000.0 logger.warning( - f"Rate limit exceeded. Waiting for {wait_time} seconds before retrying (extracted from 'retry-after-ms' header)...") + f"Rate limit exceeded. Waiting for {wait_time} seconds before retrying (extracted from 'retry-after-ms' header)..." + ) time.sleep(wait_time) else: wait_time = 30 logger.warning( - f"'retry-after' header not found. Waiting for {wait_time} seconds before retrying (default)...") + f"'retry-after' header not found. Waiting for {wait_time} seconds before retrying (default)..." + ) time.sleep(wait_time) else: - logger.error(f"HTTP error occurred with status code: {e.response.status_code}, waiting 30 seconds before retrying") + logger.error( + f"HTTP error occurred with status code: {e.response.status_code}, waiting 30 seconds before retrying" + ) time.sleep(30) except Exception as e: logger.error(f"Unexpected error occurred: {str(e)}") logger.info( - "Waiting for 30 seconds before retrying due to an unexpected error.") + "Waiting for 30 seconds before retrying due to an unexpected error." + ) time.sleep(30) continue @@ -300,7 +345,7 @@ def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: logger.debug(f"Parsing LLM result: {llmresult}") try: - if hasattr(llmresult, 'usage_metadata'): + if hasattr(llmresult, "usage_metadata"): content = llmresult.content response_metadata = llmresult.response_metadata id_ = llmresult.id @@ -310,7 +355,9 @@ def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: "content": content, "response_metadata": { "model_name": response_metadata.get("model_name", ""), - "system_fingerprint": response_metadata.get("system_fingerprint", ""), + "system_fingerprint": response_metadata.get( + "system_fingerprint", "" + ), "finish_reason": response_metadata.get("finish_reason", ""), "logprobs": response_metadata.get("logprobs", None), }, @@ -321,11 +368,11 @@ def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: "total_tokens": usage_metadata.get("total_tokens", 0), }, } - else : + else: content = llmresult.content response_metadata = llmresult.response_metadata id_ = llmresult.id - token_usage = response_metadata['token_usage'] + token_usage = response_metadata["token_usage"] parsed_result = { "content": content, @@ -339,23 +386,20 @@ def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: "output_tokens": token_usage.completion_tokens, "total_tokens": token_usage.total_tokens, }, - } + } logger.debug(f"Parsed LLM result successfully: {parsed_result}") return parsed_result except KeyError as e: - logger.error( - f"KeyError while parsing LLM result: missing key {str(e)}") + logger.error(f"KeyError while parsing LLM result: missing key {str(e)}") raise except Exception as e: - logger.error( - f"Unexpected error while parsing LLM result: {str(e)}") + logger.error(f"Unexpected error while parsing LLM result: {str(e)}") raise class GPTAnswerer: - def __init__(self, config, llm_api_key): self.ai_adapter = AIAdapter(config, llm_api_key) self.llm_cheap = LoggerChatModel(self.ai_adapter) @@ -393,7 +437,8 @@ def set_job(self, job): logger.debug(f"Setting job: {job}") self.job = job self.job.set_summarize_job_description( - self.summarize_job_description(self.job.description)) + self.summarize_job_description(self.job.description) + ) def set_job_application_profile(self, job_application_profile): logger.debug(f"Setting job application profile: {job_application_profile}") @@ -404,8 +449,7 @@ def summarize_job_description(self, text: str) -> str: strings.summarize_prompt_template = self._preprocess_template_string( strings.summarize_prompt_template ) - prompt = ChatPromptTemplate.from_template( - strings.summarize_prompt_template) + prompt = ChatPromptTemplate.from_template(strings.summarize_prompt_template) chain = prompt | self.llm_cheap | StrOutputParser() output = chain.invoke({"text": text}) logger.debug(f"Summary generated: {output}") @@ -419,15 +463,25 @@ def _create_chain(self, template: str): def answer_question_textual_wide_range(self, question: str) -> str: logger.debug(f"Answering textual question: {question}") chains = { - "personal_information": self._create_chain(strings.personal_information_template), - "self_identification": self._create_chain(strings.self_identification_template), - "legal_authorization": self._create_chain(strings.legal_authorization_template), + "personal_information": self._create_chain( + strings.personal_information_template + ), + "self_identification": self._create_chain( + strings.self_identification_template + ), + "legal_authorization": self._create_chain( + strings.legal_authorization_template + ), "work_preferences": self._create_chain(strings.work_preferences_template), "education_details": self._create_chain(strings.education_details_template), - "experience_details": self._create_chain(strings.experience_details_template), + "experience_details": self._create_chain( + strings.experience_details_template + ), "projects": self._create_chain(strings.projects_template), "availability": self._create_chain(strings.availability_template), - "salary_expectations": self._create_chain(strings.salary_expectations_template), + "salary_expectations": self._create_chain( + strings.salary_expectations_template + ), "certifications": self._create_chain(strings.certifications_template), "languages": self._create_chain(strings.languages_template), "interests": self._create_chain(strings.interests_template), @@ -528,50 +582,64 @@ def answer_question_textual_wide_range(self, question: str) -> str: r"(Personal information|Self Identification|Legal Authorization|Work Preferences|Education " r"Details|Experience Details|Projects|Availability|Salary " r"Expectations|Certifications|Languages|Interests|Cover letter)", - output, re.IGNORECASE) + output, + re.IGNORECASE, + ) if not match: - raise ValueError( - "Could not extract section name from the response.") + raise ValueError("Could not extract section name from the response.") section_name = match.group(1).lower().replace(" ", "_") if section_name == "cover_letter": chain = chains.get(section_name) output = chain.invoke( - {"resume": self.resume, "job_description": self.job_description}) + {"resume": self.resume, "job_description": self.job_description} + ) logger.debug(f"Cover letter generated: {output}") return output - resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name, - None) + resume_section = getattr(self.resume, section_name, None) or getattr( + self.job_application_profile, section_name, None + ) if resume_section is None: logger.error( - f"Section '{section_name}' not found in either resume or job_application_profile.") - raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.") + f"Section '{section_name}' not found in either resume or job_application_profile." + ) + raise ValueError( + f"Section '{section_name}' not found in either resume or job_application_profile." + ) chain = chains.get(section_name) if chain is None: logger.error(f"Chain not defined for section '{section_name}'") raise ValueError(f"Chain not defined for section '{section_name}'") - output = chain.invoke( - {"resume_section": resume_section, "question": question}) + output = chain.invoke({"resume_section": resume_section, "question": question}) logger.debug(f"Question answered: {output}") return output - def answer_question_numeric(self, question: str, default_experience: str = 3) -> str: + def answer_question_numeric( + self, question: str, default_experience: str = 3 + ) -> str: logger.debug(f"Answering numeric question: {question}") func_template = self._preprocess_template_string( - strings.numeric_question_template) + strings.numeric_question_template + ) prompt = ChatPromptTemplate.from_template(func_template) chain = prompt | self.llm_cheap | StrOutputParser() output_str = chain.invoke( - {"resume_educations": self.resume.education_details, "resume_jobs": self.resume.experience_details, - "resume_projects": self.resume.projects, "question": question}) + { + "resume_educations": self.resume.education_details, + "resume_jobs": self.resume.experience_details, + "resume_projects": self.resume.projects, + "question": question, + } + ) logger.debug(f"Raw output for numeric question: {output_str}") try: output = self.extract_number_from_string(output_str) logger.debug(f"Extracted number: {output}") except ValueError: logger.warning( - f"Failed to extract number, using default experience: {default_experience}") + f"Failed to extract number, using default experience: {default_experience}" + ) output = default_experience return output @@ -587,12 +655,12 @@ def extract_number_from_string(self, output_str): def answer_question_from_options(self, question: str, options: list[str]) -> str: logger.debug(f"Answering question from options: {question}") - func_template = self._preprocess_template_string( - strings.options_template) + func_template = self._preprocess_template_string(strings.options_template) prompt = ChatPromptTemplate.from_template(func_template) chain = prompt | self.llm_cheap | StrOutputParser() output_str = chain.invoke( - {"resume": self.resume, "question": question, "options": options}) + {"resume": self.resume, "question": question, "options": options} + ) logger.debug(f"Raw output for options question: {output_str}") best_option = self.find_best_match(output_str, options) logger.debug(f"Best option determined: {best_option}") @@ -600,7 +668,8 @@ def answer_question_from_options(self, question: str, options: list[str]) -> str def resume_or_cover(self, phrase: str) -> str: logger.debug( - f"Determining if phrase refers to resume or cover letter: {phrase}") + f"Determining if phrase refers to resume or cover letter: {phrase}" + ) prompt_template = """ Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. If the phrase contains only one word 'upload', consider it as 'cover'. From e3b861cc88b0fe8dba17528070ab99b67bf34540 Mon Sep 17 00:00:00 2001 From: Akhil Date: Mon, 11 Nov 2024 12:19:29 -0500 Subject: [PATCH 02/22] Update README.md --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1fdb353ed..77599329c 100644 --- a/README.md +++ b/README.md @@ -251,13 +251,11 @@ This file defines your job search parameters and bot behavior. Each section cont - claude: any model - gemini: any model - aiml: any model + - `llm_api_url`: - - Link of the API endpoint for the LLM model - - openai: + - Link of the API endpoint for the LLM model. (only requried for ollama) - ollama: - - claude: - - gemini: - - aiml: + - Note: To run local Ollama, follow the guidelines here: [Guide to Ollama deployment](https://github.com/ollama/ollama) ### 3. plain_text_resume.yaml From b64eb03a845b536e05bfb87947fb241870427489 Mon Sep 17 00:00:00 2001 From: Ved Gupta Date: Tue, 12 Nov 2024 18:40:31 +0530 Subject: [PATCH 03/22] Add GroqAIModel support --- requirements.txt | 1 + src/ai_hawk/llm/llm_manager.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index acd912e05..7f479286a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ jsonschema==4.23.0 jsonschema-specifications==2023.12.1 langchain==0.2.11 langchain-anthropic +langchain-groq langchain-huggingface langchain-community==0.2.10 langchain-core===0.2.36 diff --git a/src/ai_hawk/llm/llm_manager.py b/src/ai_hawk/llm/llm_manager.py index b5d5be4b6..367f1607a 100644 --- a/src/ai_hawk/llm/llm_manager.py +++ b/src/ai_hawk/llm/llm_manager.py @@ -29,6 +29,16 @@ class AIModel(ABC): def invoke(self, prompt: str) -> str: pass +class GroqAIModel(AIModel): + def __init__(self, api_key: str, llm_model: str): + from langchain_groq import ChatGroq + self.model = ChatGroq(model=llm_model, api_key=api_key, + temperature=0.4) + + def invoke(self, prompt: str) -> BaseMessage: + response = self.model.invoke(prompt) + logger.debug("Invoking GroqAI API") + return response class OpenAIModel(AIModel): def __init__(self, api_key: str, llm_model: str): @@ -123,7 +133,9 @@ def _create_model(self, config: dict, api_key: str) -> AIModel: elif llm_model_type == "gemini": return GeminiModel(api_key, llm_model) elif llm_model_type == "huggingface": - return HuggingFaceModel(api_key, llm_model) + return HuggingFaceModel(api_key, llm_model) + elif llm_model_type == "groq": + return GroqAIModel(api_key, llm_model) else: raise ValueError(f"Unsupported model type: {llm_model_type}") @@ -133,7 +145,7 @@ def invoke(self, prompt: str) -> str: class LLMLogger: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel, GroqAIModel]): self.llm = llm logger.debug(f"LLMLogger successfully initialized with LLM: {llm}") @@ -241,7 +253,7 @@ def log_request(prompts, parsed_reply: Dict[str, Dict]): class LoggerChatModel: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel, GroqAIModel]): self.llm = llm logger.debug(f"LoggerChatModel successfully initialized with LLM: {llm}") From b8d7ffa6202eee3756d2cc735e3f59f31dc9040d Mon Sep 17 00:00:00 2001 From: Ved Gupta Date: Wed, 13 Nov 2024 08:44:19 +0530 Subject: [PATCH 04/22] README.md updated for GROQ API --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f7be1b438..7d98d9493 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Auto_Jobs_Applier_AIHawk steps in as a game-changing solution to these challenge This file contains sensitive information. Never share or commit this file to version control. -- `llm_api_key: [Your OpenAI or Ollama API key or Gemini API key]` +- `llm_api_key: [Your OpenAI or Ollama API key or Gemini API key or Groq API key]` - Replace with your OpenAI API key for GPT integration - To obtain an API key, follow the tutorial at: - Note: You need to add credit to your OpenAI account to use the API. You can add credit by visiting the [OpenAI billing dashboard](https://platform.openai.com/account/billing). @@ -158,6 +158,7 @@ This file contains sensitive information. Never share or commit this file to ver OpenAI will update your account automatically, but it might take some time, ranging from a couple of hours to a few days. You can find more about your organization limits on the [official page](https://platform.openai.com/settings/organization/limits). - For obtaining Gemini API key visit [Google AI for Devs](https://ai.google.dev/gemini-api/docs/api-key) + - For obtaining Groq API key visit [Groq API](https://api.groq.com/v1) ### 2. config.yaml @@ -235,19 +236,21 @@ This file defines your job search parameters and bot behavior. Each section cont #### 2.1 config.yaml - Customize LLM model endpoint - `llm_model_type`: - - Choose the model type, supported: openai / ollama / claude / gemini + - Choose the model type, supported: openai / ollama / claude / gemini / groq - `llm_model`: - Choose the LLM model, currently supported: - openai: gpt-4o - ollama: llama2, mistral:v0.3 - claude: any model - gemini: any model + - groq: llama3-groq-70b-8192-tool-use-preview, llama3-groq-8b-8192-tool-use-preview, llama-3.1-70b-versatile, llama-3.1-8b-instant, llama-3.2-3b-preview, llama3-70b-8192, llama3-8b-8192, mixtral-8x7b-32768 - `llm_api_url`: - Link of the API endpoint for the LLM model - openai: - ollama: - claude: - gemini: + - groq: - Note: To run local Ollama, follow the guidelines here: [Guide to Ollama deployment](https://github.com/ollama/ollama) ### 3. plain_text_resume.yaml From 3a9e5b70621455179682c3ef501b25c1fd7727d8 Mon Sep 17 00:00:00 2001 From: Akhil Date: Tue, 12 Nov 2024 22:26:40 -0500 Subject: [PATCH 05/22] Update llm_manager.py repalced union with base class. --- src/ai_hawk/llm/llm_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ai_hawk/llm/llm_manager.py b/src/ai_hawk/llm/llm_manager.py index 367f1607a..41c1d4df7 100644 --- a/src/ai_hawk/llm/llm_manager.py +++ b/src/ai_hawk/llm/llm_manager.py @@ -145,7 +145,7 @@ def invoke(self, prompt: str) -> str: class LLMLogger: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel, GroqAIModel]): + def __init__(self, llm: AIModel): self.llm = llm logger.debug(f"LLMLogger successfully initialized with LLM: {llm}") @@ -253,7 +253,7 @@ def log_request(prompts, parsed_reply: Dict[str, Dict]): class LoggerChatModel: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel, GroqAIModel]): + def __init__(self, llm: AIModel): self.llm = llm logger.debug(f"LoggerChatModel successfully initialized with LLM: {llm}") @@ -642,4 +642,4 @@ def is_job_suitable(self): logger.info(f"Job suitability score: {score}") if int(score) < 7 : logger.debug(f"Job is not suitable: {reasoning}") - return int(score) >= 7 \ No newline at end of file + return int(score) >= 7 From 62802962bc7392172da872991f0dd0be98ce90cf Mon Sep 17 00:00:00 2001 From: Ved Gupta Date: Thu, 14 Nov 2024 01:52:00 +0000 Subject: [PATCH 06/22] groq added in constants --- constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/constants.py b/constants.py index c750c04b1..adecef032 100644 --- a/constants.py +++ b/constants.py @@ -68,5 +68,6 @@ CLAUDE = "claude" OLLAMA = "ollama" GEMINI = "gemini" +GROQ = "groq" HUGGINGFACE = "huggingface" PERPLEXITY = "perplexity" From 306fca678b918afe3ac4775094f82311ff4c11d2 Mon Sep 17 00:00:00 2001 From: Ved Gupta Date: Thu, 14 Nov 2024 10:43:26 +0000 Subject: [PATCH 07/22] just groq version added in requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7f479286a..f92a88985 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ jsonschema==4.23.0 jsonschema-specifications==2023.12.1 langchain==0.2.11 langchain-anthropic -langchain-groq +langchain-groq==0.1.9 langchain-huggingface langchain-community==0.2.10 langchain-core===0.2.36 From fd69fc0587a51a1377d3eb9d0d0ead93876f49dd Mon Sep 17 00:00:00 2001 From: Yauheni Muravitski Date: Sun, 17 Nov 2024 00:02:29 +0400 Subject: [PATCH 08/22] perf: :zap: Optimize prompt for better caching Some providers (as OpenAI) support reducing cost of calls by caching repetitive content, but the feature works only with the initial portion (prefix) of prompts --- src/ai_hawk/llm/prompts.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ai_hawk/llm/prompts.py b/src/ai_hawk/llm/prompts.py index cc7bc80a1..59586b2a4 100644 --- a/src/ai_hawk/llm/prompts.py +++ b/src/ai_hawk/llm/prompts.py @@ -262,17 +262,17 @@ - Do not include any introductions, explanations, or additional information. - The letter should be formatted into paragraph. +## My resume: +``` +{resume} +``` + ## Company Name: {company} - ## Job Description: ``` {job_description} ``` -## My resume: -``` -{resume} -``` """ numeric_question_template = """ @@ -432,10 +432,10 @@ is_relavant_position_template = """ Evaluate whether the provided resume meets the requirements outlined in the job description. Determine if the candidate is suitable for the job based on the information provided. -Job Description: {job_description} - Resume: {resume} +Job Description: {job_description} + Instructions: 1. Extract the key requirements from the job description, identifying hard requirements (must-haves) and soft requirements (nice-to-haves). 2. Identify the relevant qualifications from the resume. From c5955bdd1295df232cd5f6a63df4e50e00a3f524 Mon Sep 17 00:00:00 2001 From: "_.okti" <54273355+OctavianTheI@users.noreply.github.com> Date: Mon, 18 Nov 2024 21:07:34 +0100 Subject: [PATCH 09/22] Add AI/ML API info included links to AI/ML API docs and the endpoint to be used in the code. --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f8878a21e..cc6f6aaf3 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ Auto_Jobs_Applier_AIHawk steps in as a game-changing solution to these challenge This file contains sensitive information. Never share or commit this file to version control. -- `llm_api_key: [Your OpenAI or Ollama API key or Gemini API key or Groq API key]` +- `llm_api_key: [Your OpenAI or Ollama API key or Gemini API key or Groq API key or AI/ML API key]` - Replace with your OpenAI API key for GPT integration - To obtain an API key, follow the tutorial at: - Note: You need to add credit to your OpenAI account to use the API. You can add credit by visiting the [OpenAI billing dashboard](https://platform.openai.com/account/billing). @@ -189,6 +189,7 @@ This file contains sensitive information. Never share or commit this file to ver You can find more about your organization limits on the [official page](https://platform.openai.com/settings/organization/limits). - For obtaining Gemini API key visit [Google AI for Devs](https://ai.google.dev/gemini-api/docs/api-key) - For obtaining Groq API key visit [Groq API](https://api.groq.com/v1) + - For obtaining AI/ML API key visite [AI/ML API](https://aimlapi.com/app/) ### 2. work_preferences.yaml @@ -266,7 +267,7 @@ This file defines your job search parameters and bot behavior. Each section cont #### 2.1 config.py - Customize LLM model endpoint - `LLM_MODEL_TYPE`: - - Choose the model type, supported: openai / ollama / claude / gemini / groq + - Choose the model type, supported: openai / ollama / claude / gemini / groq / aiml - `LLM_MODEL`: - Choose the LLM model, currently supported: - openai: gpt-4o @@ -282,6 +283,7 @@ This file defines your job search parameters and bot behavior. Each section cont - claude: - gemini: - groq: + - aiml: - Note: To run local Ollama, follow the guidelines here: [Guide to Ollama deployment](https://github.com/ollama/ollama) ### 3. plain_text_resume.yaml @@ -723,6 +725,8 @@ For further assistance, please create an issue on the [GitHub repository](https: - Written by Rushi, [Linkedin](https://www.linkedin.com/in/rushichaganti/), support him by following. - [OpenAI API Documentation](https://platform.openai.com/docs/) + +- [AI/ML API Documentation](https://docs.aimlapi.com/) ### For Developers From 1f68c277957b89853e3d0f274d676d0bd8df08f5 Mon Sep 17 00:00:00 2001 From: Namami Shanker Date: Thu, 21 Nov 2024 22:33:59 +0530 Subject: [PATCH 10/22] "Log time in Job application result" (cherry picked from commit 6b2371569e22f4e3db3e4bef6c90014ffd224768) --- src/ai_hawk/job_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index a9be82a47..115aee704 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -5,6 +5,7 @@ from itertools import product from pathlib import Path from turtle import color +from datetime import datetime from inputimeout import inputimeout, TimeoutOccurred from selenium.common.exceptions import NoSuchElementException @@ -406,7 +407,8 @@ def write_to_file(self, job : Job, file_name, reason=None): "link": job.link, "job_recruiter": job.recruiter_link, "job_location": job.location, - "pdf_path": pdf_path + "pdf_path": pdf_path, + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") } if reason: From a349a4aa7230b75f00e152289f3b45544c027d21 Mon Sep 17 00:00:00 2001 From: Namami Shanker Date: Thu, 21 Nov 2024 23:16:55 +0530 Subject: [PATCH 11/22] "Refactor logging current time" (cherry picked from commit c75f78f038435785e34544168eb252e5238d7aa4) --- src/ai_hawk/job_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index 115aee704..818ef7f59 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -401,6 +401,7 @@ def write_to_file(self, job : Job, file_name, reason=None): logger.debug(f"Writing job application result to file: {file_name}") pdf_path = Path(job.resume_path).resolve() pdf_path = pdf_path.as_uri() + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") data = { "company": job.company, "job_title": job.title, @@ -408,7 +409,7 @@ def write_to_file(self, job : Job, file_name, reason=None): "job_recruiter": job.recruiter_link, "job_location": job.location, "pdf_path": pdf_path, - "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "time": current_time } if reason: From 48481b5e951e850b9aac5dae554528333bd796ac Mon Sep 17 00:00:00 2001 From: Timothy Genz <34835862+Tgenz1213@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:09:22 -0600 Subject: [PATCH 12/22] Add files via upload --- .github/pull_request_template.md | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..27f0f78aa --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,47 @@ +# Pull Request + +## Overview + +- **Title**: [Descriptive title of the changes] +- **Related Issues**: #[issue number] +- **Type**: + - [ ] Feature + - [ ] Bug Fix + - [ ] Refactor + - [ ] Documentation + - [ ] Other: + +## Description + +[Provide a clear description of the changes and their purpose] + +## Implementation Details + +- [ ] Changes are focused and solve the stated problem +- [ ] Code follows project style guides +- [ ] Complex logic is documented +- [ ] No unnecessary complexity introduced + +## Testing + +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] Manual testing completed +- [ ] All tests passing + +## Documentation & Quality + +- [ ] Project documentation updated +- [ ] Code reviewed for clarity +- [ ] Breaking changes clearly marked +- [ ] Dependencies documented + +## Deployment Impact + +- [ ] Database migrations required? [Yes/No] +- [ ] Configuration changes needed? [Yes/No] +- [ ] Breaking changes? [Yes/No] + +## Additional Notes + +[Add any other context or notes for reviewers] From f21530dc055f8fa5c1f1d449c814654489bc150a Mon Sep 17 00:00:00 2001 From: Timothy Genz <34835862+Tgenz1213@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:26:38 -0600 Subject: [PATCH 13/22] Update pull_request_template.md --- .github/pull_request_template.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 27f0f78aa..71d8b1eed 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,5 @@ -# Pull Request - -## Overview - - **Title**: [Descriptive title of the changes] +- **Description**: [Provide a clear description of the changes and their purpose] - **Related Issues**: #[issue number] - **Type**: - [ ] Feature @@ -11,10 +8,6 @@ - [ ] Documentation - [ ] Other: -## Description - -[Provide a clear description of the changes and their purpose] - ## Implementation Details - [ ] Changes are focused and solve the stated problem From 86ef242bfafa3bd584431a8395aefb94ba0ebb7c Mon Sep 17 00:00:00 2001 From: Yauheni Muravitski Date: Sat, 23 Nov 2024 23:11:41 +0400 Subject: [PATCH 14/22] fix: :bug: Change selectors due to new html structure LinkedIn has changed their HTML structure therefore app stopped to apply to jobs new --- src/ai_hawk/job_manager.py | 45 +++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index 818ef7f59..fee4b15b2 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -253,17 +253,17 @@ def get_jobs_from_page(self): pass try: - job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") - browser_utils.scroll_slow(self.driver, job_results) - browser_utils.scroll_slow(self.driver, job_results, step=300, reverse=True) + jobs_container = self.driver.find_element(By.XPATH, '//*[@id="main"]/div/div[2]/div[1]/div') + browser_utils.scroll_slow(self.driver, jobs_container) + browser_utils.scroll_slow(self.driver, jobs_container, step=300, reverse=True) - job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[ - 0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') - if not job_list_elements: + job_list = self.driver.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') + + if not job_list: logger.debug("No job class elements found on page, skipping.") return [] - return job_list_elements + return job_list except NoSuchElementException: logger.debug("No job results found on the page.") @@ -281,13 +281,14 @@ def read_jobs(self): except NoSuchElementException: pass - job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") - browser_utils.scroll_slow(self.driver, job_results) - browser_utils.scroll_slow(self.driver, job_results, step=300, reverse=True) - job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') - if not job_list_elements: - raise Exception("No job class elements found on page") - job_list = [self.job_tile_to_job(job_element) for job_element in job_list_elements] + jobs_container = self.driver.find_element(By.XPATH, '//*[@id="main"]/div/div[2]/div[1]/div') + browser_utils.scroll_slow(self.driver, jobs_container) + browser_utils.scroll_slow(self.driver, jobs_container, step=300, reverse=True) + + job_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') + if not job_list: + raise Exception("No job elements found on page") + job_list = [self.job_tile_to_job(job_element) for job_element in job_list] for job in job_list: if self.is_blacklisted(job.title, job.company, job.link, job.location): logger.info(f"Blacklisted {job.title} at {job.company} in {job.location}, skipping...") @@ -308,14 +309,14 @@ def apply_jobs(self): except NoSuchElementException: pass - job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[ - 0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') + jobs_container = self.driver.find_element(By.XPATH, '//*[@id="main"]/div/div[2]/div[1]/div') + job_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') - if not job_list_elements: + if not job_list: logger.debug("No job class elements found on page, skipping") return - job_list = [self.job_tile_to_job(job_element) for job_element in job_list_elements] + job_list = [self.job_tile_to_job(job_element) for job_element in job_list] for job in job_list: @@ -490,9 +491,9 @@ def job_tile_to_job(self, job_tile) -> Job: logger.debug(f"Job link extracted: {job.link}") except NoSuchElementException: logger.warning("Job link is missing.") - + try: - job.company = job_tile.find_element(By.CLASS_NAME, 'job-card-container__primary-description').text + job.company = job_tile.find_element(By.XPATH, './/span[contains(normalize-space(), " · ")]').text.split(' · ')[0].strip() logger.debug(f"Job company extracted: {job.company}") except NoSuchElementException: logger.warning("Job company is missing.") @@ -509,12 +510,12 @@ def job_tile_to_job(self, job_tile) -> Job: logger.warning(f"Failed to extract job ID: {e}", exc_info=True) try: - job.location = job_tile.find_element(By.CLASS_NAME, 'job-card-container__metadata-item').text + job.location = job_tile.find_element(By.XPATH, './/span[contains(normalize-space(), " · ")]').text.split(' · ')[-1].strip() except NoSuchElementException: logger.warning("Job location is missing.") try: - job.apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text + job.apply_method = job_tile.find_element(By.XPATH, ".//div[contains(@class, 'job-card-container__job-insight-text') and normalize-space() = 'Easy Apply']").text except NoSuchElementException: job.apply_method = "Applied" logger.warning("Apply method not found, assuming 'Applied'.") From a2bfb043569fb7bb155fe47c055fead2b7cecca0 Mon Sep 17 00:00:00 2001 From: Yauheni Muravitski Date: Sun, 24 Nov 2024 02:09:29 +0400 Subject: [PATCH 15/22] test: :white_check_mark: Fix test after removing other developer's hack Test had been failing after removing unnecessary wheel ( .find_elements()[0] ) n --- tests/test_aihawk_job_manager.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_aihawk_job_manager.py b/tests/test_aihawk_job_manager.py index cc4750595..6dafe4ace 100644 --- a/tests/test_aihawk_job_manager.py +++ b/tests/test_aihawk_job_manager.py @@ -114,25 +114,25 @@ def test_apply_jobs_with_jobs(mocker, job_manager): # Mock no_jobs_element to simulate the absence of "No matching jobs found" banner no_jobs_element = mocker.Mock() no_jobs_element.text = "" # Empty text means "No matching jobs found" is not present - mocker.patch.object(job_manager.driver, 'find_element', - return_value=no_jobs_element) # Mock the page_source to simulate what the page looks like when jobs are present mocker.patch.object(job_manager.driver, 'page_source', return_value="some job content") - # Mock the outer find_elements (scaffold-layout__list-container) + # Mock the outer find_element container_mock = mocker.Mock() # Mock the inner find_elements to return job list items job_element_mock = mocker.Mock() # Simulating two job items - job_elements_list = [job_element_mock, job_element_mock] + job_list = [job_element_mock, job_element_mock] # Return the container mock, which itself returns the job elements list - container_mock.find_elements.return_value = job_elements_list - mocker.patch.object(job_manager.driver, 'find_elements', - return_value=[container_mock]) + container_mock.find_elements.return_value = job_list + mocker.patch.object(job_manager.driver, 'find_element', side_effect=[ + no_jobs_element, + container_mock + ]) job = Job( title="Title", @@ -181,7 +181,8 @@ def test_apply_jobs_with_jobs(mocker, job_manager): job_manager.apply_jobs() # Assertions - assert job_manager.driver.find_elements.call_count == 1 + assert container_mock.find_elements.call_count == 1 + assert job_manager.driver.find_element.call_count == 2 # Called for each job element assert job_manager.job_tile_to_job.call_count == 2 # Called for each job element From e18fcd303d89d14b178c6712180abcdfd5c4fba3 Mon Sep 17 00:00:00 2001 From: Akhil Date: Sun, 24 Nov 2024 15:26:40 -0500 Subject: [PATCH 16/22] fixes [BUG]: Not applying to jobs #919 --- src/ai_hawk/job_manager.py | 43 ++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index a9be82a47..1e7c33305 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -4,6 +4,7 @@ import time from itertools import product from pathlib import Path +import traceback from turtle import color from inputimeout import inputimeout, TimeoutOccurred @@ -13,6 +14,7 @@ from ai_hawk.linkedIn_easy_applier import AIHawkEasyApplier from config import JOB_MAX_APPLICATIONS, JOB_MIN_APPLICATIONS, MINIMUM_WAIT_TIME_IN_SECONDS +import job from src.job import Job from src.logging import logger @@ -155,7 +157,7 @@ def start_applying(self): logger.debug("Starting the application process for this page...") try: - jobs = self.get_jobs_from_page() + jobs = self.get_jobs_from_page(scroll=True) if not jobs: logger.debug("No more jobs found on this page. Exiting loop.") break @@ -166,7 +168,7 @@ def start_applying(self): try: self.apply_jobs() except Exception as e: - logger.error(f"Error during job application: {e}") + logger.error(f"Error during job application: {e} {traceback.format_exc()}") continue logger.debug("Applying to jobs on this page has been completed!") @@ -239,7 +241,7 @@ def start_applying(self): time.sleep(sleep_time) page_sleep += 1 - def get_jobs_from_page(self): + def get_jobs_from_page(self, scroll=False): try: @@ -252,24 +254,30 @@ def get_jobs_from_page(self): pass try: - job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") - browser_utils.scroll_slow(self.driver, job_results) - browser_utils.scroll_slow(self.driver, job_results, step=300, reverse=True) + # XPath query to find the ul tag with class scaffold-layout__list-container + job_results_xpath_query = "//ul[contains(@class, 'scaffold-layout__list-container')]" + job_results = self.driver.find_element(By.XPATH, job_results_xpath_query) - job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[ - 0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') + if scroll: + job_results_scrolableElament = job_results.find_element(By.XPATH,"..") + logger.warning(f'is scrollable: {browser_utils.is_scrollable(job_results_scrolableElament)}') + + browser_utils.scroll_slow(self.driver, job_results_scrolableElament) + browser_utils.scroll_slow(self.driver, job_results_scrolableElament, step=300, reverse=True) + + job_list_elements = job_results.find_elements(By.XPATH, ".//li[contains(@class, 'jobs-search-results__list-item') and contains(@class, 'ember-view')]") if not job_list_elements: logger.debug("No job class elements found on page, skipping.") return [] return job_list_elements - except NoSuchElementException: - logger.debug("No job results found on the page.") + except NoSuchElementException as e: + logger.warning(f'No job results found on the page. \n expection: {traceback.format_exc()}') return [] except Exception as e: - logger.error(f"Error while fetching job elements: {e}") + logger.error(f"Error while fetching job elements: {e} {traceback.format_exc()}") return [] def read_jobs(self): @@ -307,8 +315,7 @@ def apply_jobs(self): except NoSuchElementException: pass - job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[ - 0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') + job_list_elements = self.get_jobs_from_page() if not job_list_elements: logger.debug("No job class elements found on page, skipping") @@ -489,10 +496,10 @@ def job_tile_to_job(self, job_tile) -> Job: logger.warning("Job link is missing.") try: - job.company = job_tile.find_element(By.CLASS_NAME, 'job-card-container__primary-description').text + job.company = job_tile.find_element(By.XPATH, ".//div[contains(@class, 'artdeco-entity-lockup__subtitle')]//span").text logger.debug(f"Job company extracted: {job.company}") - except NoSuchElementException: - logger.warning("Job company is missing.") + except NoSuchElementException as e: + logger.warning(f'Job company is missing. {e} {traceback.format_exc()}') # Extract job ID from job url try: @@ -512,9 +519,9 @@ def job_tile_to_job(self, job_tile) -> Job: try: job.apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text - except NoSuchElementException: + except NoSuchElementException as e: job.apply_method = "Applied" - logger.warning("Apply method not found, assuming 'Applied'.") + logger.warning(f'Apply method not found, assuming \'Applied\'. {e} {traceback.format_exc()}') return job From 2dd187a9a134269ee53846772baadf57117a6bd0 Mon Sep 17 00:00:00 2001 From: Yauheni Muravitski Date: Mon, 25 Nov 2024 01:24:38 +0400 Subject: [PATCH 17/22] fix: Add classes for temporary solution Current solution is fine for hotfix, but not as constant --- src/ai_hawk/job_manager.py | 6 +++--- src/ai_hawk/linkedIn_easy_applier.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index fee4b15b2..018233e96 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -253,7 +253,7 @@ def get_jobs_from_page(self): pass try: - jobs_container = self.driver.find_element(By.XPATH, '//*[@id="main"]/div/div[2]/div[1]/div') + jobs_container = self.driver.find_element(By.CLASS_NAME, 'scaffold-layout__list-container') browser_utils.scroll_slow(self.driver, jobs_container) browser_utils.scroll_slow(self.driver, jobs_container, step=300, reverse=True) @@ -281,7 +281,7 @@ def read_jobs(self): except NoSuchElementException: pass - jobs_container = self.driver.find_element(By.XPATH, '//*[@id="main"]/div/div[2]/div[1]/div') + jobs_container = self.driver.find_element(By.CLASS_NAME, 'scaffold-layout__list-container') browser_utils.scroll_slow(self.driver, jobs_container) browser_utils.scroll_slow(self.driver, jobs_container, step=300, reverse=True) @@ -309,7 +309,7 @@ def apply_jobs(self): except NoSuchElementException: pass - jobs_container = self.driver.find_element(By.XPATH, '//*[@id="main"]/div/div[2]/div[1]/div') + jobs_container = self.driver.find_element(By.CLASS_NAME, 'scaffold-layout__list-container') job_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') if not job_list: diff --git a/src/ai_hawk/linkedIn_easy_applier.py b/src/ai_hawk/linkedIn_easy_applier.py index f0fea7abd..257b0ee99 100644 --- a/src/ai_hawk/linkedIn_easy_applier.py +++ b/src/ai_hawk/linkedIn_easy_applier.py @@ -376,8 +376,8 @@ def fill_up(self, job_context : JobContext) -> None: EC.presence_of_element_located((By.CLASS_NAME, 'jobs-easy-apply-content')) ) - pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4') - for element in pb4_elements: + input_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping') + for element in input_elements: self._process_form_element(element, job_context) except Exception as e: logger.error(f"Failed to find form elements: {e}") From ba3a0879d57a2978adcad517164a2623a789cc8f Mon Sep 17 00:00:00 2001 From: Akhil Date: Sun, 24 Nov 2024 16:38:16 -0500 Subject: [PATCH 18/22] test case fixes --- tests/test_aihawk_job_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_aihawk_job_manager.py b/tests/test_aihawk_job_manager.py index cc4750595..4b46cc0b6 100644 --- a/tests/test_aihawk_job_manager.py +++ b/tests/test_aihawk_job_manager.py @@ -129,11 +129,11 @@ def test_apply_jobs_with_jobs(mocker, job_manager): # Simulating two job items job_elements_list = [job_element_mock, job_element_mock] - # Return the container mock, which itself returns the job elements list - container_mock.find_elements.return_value = job_elements_list mocker.patch.object(job_manager.driver, 'find_elements', return_value=[container_mock]) + mocker.patch.object(job_manager, 'get_jobs_from_page', return_value=job_elements_list) + job = Job( title="Title", company="Company", @@ -181,7 +181,7 @@ def test_apply_jobs_with_jobs(mocker, job_manager): job_manager.apply_jobs() # Assertions - assert job_manager.driver.find_elements.call_count == 1 + assert job_manager.get_jobs_from_page.call_count == 1 # Called for each job element assert job_manager.job_tile_to_job.call_count == 2 # Called for each job element From dba7b109a320ddea96425fe926ec6ad055b82b2b Mon Sep 17 00:00:00 2001 From: Yauheni Muravitski Date: Mon, 25 Nov 2024 03:38:58 +0400 Subject: [PATCH 19/22] Rewrite test, merge solution with changes, deduplicate code squash --- src/ai_hawk/job_manager.py | 22 ++---------- tests/test_aihawk_job_manager.py | 60 ++++++++++++++------------------ 2 files changed, 29 insertions(+), 53 deletions(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index 4feb4b813..6225b194a 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -244,7 +244,6 @@ def start_applying(self): def get_jobs_from_page(self, scroll=False): try: - no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): logger.debug("No matching jobs found on this page, skipping.") @@ -262,11 +261,8 @@ def get_jobs_from_page(self, scroll=False): browser_utils.scroll_slow(self.driver, jobs_container_scrolableElement) browser_utils.scroll_slow(self.driver, jobs_container_scrolableElement, step=300, reverse=True) - jobs_container = self.driver.find_element(By.CLASS_NAME, 'scaffold-layout__list-container') - browser_utils.scroll_slow(self.driver, jobs_container) - browser_utils.scroll_slow(self.driver, jobs_container, step=300, reverse=True) - job_list = self.driver.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') + job_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') if not job_list: logger.debug("No job class elements found on page, skipping.") @@ -310,19 +306,7 @@ def read_jobs(self): continue def apply_jobs(self): - try: - no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') - if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): - logger.debug("No matching jobs found on this page, skipping") - return - except NoSuchElementException: - pass - - job_list_elements = self.get_jobs_from_page() - - if not job_list: - logger.debug("No job class elements found on page, skipping") - return + job_list = self.get_jobs_from_page() job_list = [self.job_tile_to_job(job_element) for job_element in job_list] @@ -521,7 +505,7 @@ def job_tile_to_job(self, job_tile) -> Job: logger.warning("Job location is missing.") try: - job.apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text + job.apply_method = job_tile.find_element(By.XPATH, ".//div[contains(@class, 'job-card-container__job-insight-text') and normalize-space() = 'Easy Apply']").text except NoSuchElementException as e: job.apply_method = "Applied" logger.warning(f'Apply method not found, assuming \'Applied\'. {e} {traceback.format_exc()}') diff --git a/tests/test_aihawk_job_manager.py b/tests/test_aihawk_job_manager.py index 1f7f137c6..96eb10e1d 100644 --- a/tests/test_aihawk_job_manager.py +++ b/tests/test_aihawk_job_manager.py @@ -71,21 +71,33 @@ def test_get_jobs_from_page_no_jobs(mocker, job_manager): def test_get_jobs_from_page_with_jobs(mocker, job_manager): """Test get_jobs_from_page when job elements are found.""" - # Mock the no_jobs_element to behave correctly - mock_no_jobs_element = mocker.Mock() - mock_no_jobs_element.text = "No matching jobs found" + # Mock no_jobs_element to simulate the absence of "No matching jobs found" banner + no_jobs_element = mocker.Mock() + no_jobs_element.text = "" # Empty text means "No matching jobs found" is not present - # Mocking the find_element to return the mock no_jobs_element - mocker.patch.object(job_manager.driver, 'find_element', - return_value=mock_no_jobs_element) + # Mock the driver to simulate the page source + mocker.patch.object(job_manager.driver, 'page_source', return_value="") - # Mock the page_source - mocker.patch.object(job_manager.driver, 'page_source', - return_value="some page content") + # Mock the outer find_element + container_mock = mocker.Mock() - # Ensure jobs are returned as empty list due to "No matching jobs found" - jobs = job_manager.get_jobs_from_page() - assert jobs == [] # No jobs expected due to "No matching jobs found" + # Mock the inner find_elements to return job list items + job_element_mock = mocker.Mock() + # Simulating two job items + job_elements_list = [job_element_mock, job_element_mock] + + # Return the container mock, which itself returns the job elements list + container_mock.find_elements.return_value = job_elements_list + mocker.patch.object(job_manager.driver, 'find_element', side_effect=[ + no_jobs_element, + container_mock + ]) + + job_manager.get_jobs_from_page() + + assert job_manager.driver.find_element.call_count == 2 + assert container_mock.find_elements.call_count == 1 + def test_apply_jobs_with_no_jobs(mocker, job_manager): @@ -94,9 +106,6 @@ def test_apply_jobs_with_no_jobs(mocker, job_manager): mock_element = mocker.Mock() mock_element.text = "No matching jobs found" - # Mock the driver to simulate the page source - mocker.patch.object(job_manager.driver, 'page_source', return_value="") - # Mock the driver to return the mock element when find_element is called mocker.patch.object(job_manager.driver, 'find_element', return_value=mock_element) @@ -111,28 +120,13 @@ def test_apply_jobs_with_no_jobs(mocker, job_manager): def test_apply_jobs_with_jobs(mocker, job_manager): """Test apply_jobs when jobs are present.""" - # Mock no_jobs_element to simulate the absence of "No matching jobs found" banner - no_jobs_element = mocker.Mock() - no_jobs_element.text = "" # Empty text means "No matching jobs found" is not present - # Mock the page_source to simulate what the page looks like when jobs are present mocker.patch.object(job_manager.driver, 'page_source', return_value="some job content") - # Mock the outer find_element - container_mock = mocker.Mock() - - # Mock the inner find_elements to return job list items + # Simulating two job elements job_element_mock = mocker.Mock() - # Simulating two job items - job_list = [job_element_mock, job_element_mock] - - # Return the container mock, which itself returns the job elements list - container_mock.find_elements.return_value = job_list - mocker.patch.object(job_manager.driver, 'find_element', side_effect=[ - no_jobs_element, - container_mock - ]) + job_elements_list = [job_element_mock, job_element_mock] mocker.patch.object(job_manager, 'get_jobs_from_page', return_value=job_elements_list) @@ -184,8 +178,6 @@ def test_apply_jobs_with_jobs(mocker, job_manager): # Assertions assert job_manager.get_jobs_from_page.call_count == 1 - assert container_mock.find_elements.call_count == 1 - assert job_manager.driver.find_element.call_count == 2 # Called for each job element assert job_manager.job_tile_to_job.call_count == 2 # Called for each job element From 8411cf8c1a321188eed7cd93de326bfe6210b71d Mon Sep 17 00:00:00 2001 From: Yauheni Muravitski Date: Mon, 25 Nov 2024 04:12:28 +0400 Subject: [PATCH 20/22] style: :art: Fix naming back --- src/ai_hawk/job_manager.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index 6225b194a..f361c2bb0 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -262,13 +262,13 @@ def get_jobs_from_page(self, scroll=False): browser_utils.scroll_slow(self.driver, jobs_container_scrolableElement) browser_utils.scroll_slow(self.driver, jobs_container_scrolableElement, step=300, reverse=True) - job_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') + job_element_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') - if not job_list: + if not job_element_list: logger.debug("No job class elements found on page, skipping.") return [] - return job_list + return job_element_list except NoSuchElementException as e: logger.warning(f'No job results found on the page. \n expection: {traceback.format_exc()}') @@ -290,10 +290,10 @@ def read_jobs(self): browser_utils.scroll_slow(self.driver, jobs_container) browser_utils.scroll_slow(self.driver, jobs_container, step=300, reverse=True) - job_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') - if not job_list: + job_element_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') + if not job_element_list: raise Exception("No job elements found on page") - job_list = [self.job_tile_to_job(job_element) for job_element in job_list] + job_list = [self.job_tile_to_job(job_element) for job_element in job_element_list] for job in job_list: if self.is_blacklisted(job.title, job.company, job.link, job.location): logger.info(f"Blacklisted {job.title} at {job.company} in {job.location}, skipping...") @@ -306,9 +306,9 @@ def read_jobs(self): continue def apply_jobs(self): - job_list = self.get_jobs_from_page() + job_element_list = self.get_jobs_from_page() - job_list = [self.job_tile_to_job(job_element) for job_element in job_list] + job_list = [self.job_tile_to_job(job_element) for job_element in job_element_list] for job in job_list: From 22f2c3b28c16e8b5ee23931dd753015b4a1e4549 Mon Sep 17 00:00:00 2001 From: Akhil Date: Mon, 25 Nov 2024 08:09:36 -0500 Subject: [PATCH 21/22] reveiw changes --- src/ai_hawk/job_manager.py | 36 ++++++++++++++------------------ tests/test_aihawk_job_manager.py | 6 +++--- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index f361c2bb0..45515134c 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -253,7 +253,9 @@ def get_jobs_from_page(self, scroll=False): pass try: - jobs_container = self.driver.find_element(By.CLASS_NAME, 'scaffold-layout__list-container') + # XPath query to find the ul tag with class scaffold-layout__list-container + jobs_xpath_query = "//ul[contains(@class, 'scaffold-layout__list-container')]" + jobs_container = self.driver.find_element(By.XPATH, jobs_xpath_query) if scroll: jobs_container_scrolableElement = jobs_container.find_element(By.XPATH,"..") @@ -262,7 +264,7 @@ def get_jobs_from_page(self, scroll=False): browser_utils.scroll_slow(self.driver, jobs_container_scrolableElement) browser_utils.scroll_slow(self.driver, jobs_container_scrolableElement, step=300, reverse=True) - job_element_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') + job_element_list = jobs_container.find_elements(By.XPATH, ".//li[contains(@class, 'jobs-search-results__list-item') and contains(@class, 'ember-view')]") if not job_element_list: logger.debug("No job class elements found on page, skipping.") @@ -279,20 +281,8 @@ def get_jobs_from_page(self, scroll=False): return [] def read_jobs(self): - try: - no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') - if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): - raise Exception("No more jobs on this page") - except NoSuchElementException: - pass - - jobs_container = self.driver.find_element(By.CLASS_NAME, 'scaffold-layout__list-container') - browser_utils.scroll_slow(self.driver, jobs_container) - browser_utils.scroll_slow(self.driver, jobs_container, step=300, reverse=True) - job_element_list = jobs_container.find_elements(By.CSS_SELECTOR, 'div[data-job-id]') - if not job_element_list: - raise Exception("No job elements found on page") + job_element_list = self.get_jobs_from_page() job_list = [self.job_tile_to_job(job_element) for job_element in job_element_list] for job in job_list: if self.is_blacklisted(job.title, job.company, job.link, job.location): @@ -483,7 +473,7 @@ def job_tile_to_job(self, job_tile) -> Job: logger.warning("Job link is missing.") try: - job.company = job_tile.find_element(By.XPATH, './/span[contains(normalize-space(), " · ")]').text.split(' · ')[0].strip() + job.company = job_tile.find_element(By.XPATH, ".//div[contains(@class, 'artdeco-entity-lockup__subtitle')]//span").text logger.debug(f"Job company extracted: {job.company}") except NoSuchElementException as e: logger.warning(f'Job company is missing. {e} {traceback.format_exc()}') @@ -500,15 +490,21 @@ def job_tile_to_job(self, job_tile) -> Job: logger.warning(f"Failed to extract job ID: {e}", exc_info=True) try: - job.location = job_tile.find_element(By.XPATH, './/span[contains(normalize-space(), " · ")]').text.split(' · ')[-1].strip() + job.location = job_tile.find_element(By.CLASS_NAME, 'job-card-container__metadata-item').text except NoSuchElementException: logger.warning("Job location is missing.") + try: - job.apply_method = job_tile.find_element(By.XPATH, ".//div[contains(@class, 'job-card-container__job-insight-text') and normalize-space() = 'Easy Apply']").text + job_state = job_tile.find_element(By.XPATH, ".//ul[contains(@class, 'job-card-list__footer-wrapper')]//li[contains(@class, 'job-card-container__apply-method')]").text except NoSuchElementException as e: - job.apply_method = "Applied" - logger.warning(f'Apply method not found, assuming \'Applied\'. {e} {traceback.format_exc()}') + try: + # Fetching state when apply method is not found + job_state = job_tile.find_element(By.XPATH, ".//ul[contains(@class, 'job-card-list__footer-wrapper')]//li[contains(@class, 'job-card-container__footer-job-state')]").text + job.apply_method = "Applied" + logger.warning(f'Apply method not found, state {job_state}. {e} {traceback.format_exc()}') + except NoSuchElementException as e: + logger.warning(f'Apply method and state not found. {e} {traceback.format_exc()}') return job diff --git a/tests/test_aihawk_job_manager.py b/tests/test_aihawk_job_manager.py index 96eb10e1d..3335ebffe 100644 --- a/tests/test_aihawk_job_manager.py +++ b/tests/test_aihawk_job_manager.py @@ -72,8 +72,8 @@ def test_get_jobs_from_page_no_jobs(mocker, job_manager): def test_get_jobs_from_page_with_jobs(mocker, job_manager): """Test get_jobs_from_page when job elements are found.""" # Mock no_jobs_element to simulate the absence of "No matching jobs found" banner - no_jobs_element = mocker.Mock() - no_jobs_element.text = "" # Empty text means "No matching jobs found" is not present + no_jobs_element_mock = mocker.Mock() + no_jobs_element_mock.text = "" # Empty text means "No matching jobs found" is not present # Mock the driver to simulate the page source mocker.patch.object(job_manager.driver, 'page_source', return_value="") @@ -89,7 +89,7 @@ def test_get_jobs_from_page_with_jobs(mocker, job_manager): # Return the container mock, which itself returns the job elements list container_mock.find_elements.return_value = job_elements_list mocker.patch.object(job_manager.driver, 'find_element', side_effect=[ - no_jobs_element, + no_jobs_element_mock, container_mock ]) From d5295fbbe60098dee8228829f3d9acfc54d0727a Mon Sep 17 00:00:00 2001 From: Akhil Date: Mon, 25 Nov 2024 08:18:12 -0500 Subject: [PATCH 22/22] review change --- src/ai_hawk/job_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index 45515134c..112af6855 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -14,7 +14,7 @@ from ai_hawk.linkedIn_easy_applier import AIHawkEasyApplier from config import JOB_MAX_APPLICATIONS, JOB_MIN_APPLICATIONS, MINIMUM_WAIT_TIME_IN_SECONDS -import job + from src.job import Job from src.logging import logger