resolve issues

This commit is contained in:
queukat 2024-09-06 20:06:30 +03:00
parent b1de845ec1
commit 35cc5d3bde
3 changed files with 126 additions and 35 deletions

View file

@ -3,6 +3,8 @@ import os
import re import re
import textwrap import textwrap
import time import time
from abc import ABC, abstractmethod
from typing import Dict, List, Union
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
@ -17,15 +19,73 @@ from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompt_values import StringPromptValue from langchain_core.prompt_values import StringPromptValue
from langchain_core.prompts import ChatPromptTemplate from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from Levenshtein import distance
import src.strings as strings import src.strings as strings
from src.utils import logger from src.utils import logger
load_dotenv() load_dotenv()
class AIModel(ABC):
@abstractmethod
def invoke(self, prompt: str) -> str:
pass
class OpenAIModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_openai import ChatOpenAI
self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key,
temperature=0.4, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
print("invoke in openai")
response = self.model.invoke(prompt)
return response
class ClaudeModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_anthropic import ChatAnthropic
self.model = ChatAnthropic(model=llm_model, api_key=api_key,
temperature=0.4, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
response = self.model.invoke(prompt)
return response
class OllamaModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_ollama import ChatOllama
self.model = ChatOllama(model=llm_model, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
response = self.model.invoke(prompt)
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_api_url = config['llm_api_url']
print('Using {0} with {1} from {2}'.format(llm_model_type, llm_model, llm_api_url))
if llm_model_type == "openai":
return OpenAIModel(api_key, llm_model, llm_api_url)
elif llm_model_type == "claude":
return ClaudeModel(api_key, llm_model, llm_api_url)
elif llm_model_type == "ollama":
return OllamaModel(api_key, llm_model, llm_api_url)
else:
raise ValueError(f"Unsupported model type: {model_type}")
def invoke(self, prompt: str) -> str:
return self.model.invoke(prompt)
class LLMLogger: class LLMLogger:
def __init__(self, llm: ChatOpenAI): def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]):
logger.debug("Initializing LLMLogger with LLM: %s", llm) logger.debug("Initializing LLMLogger with LLM: %s", llm)
self.llm = llm self.llm = llm
logger.debug("LLMLogger successfully initialized with LLM: %s", llm) logger.debug("LLMLogger successfully initialized with LLM: %s", llm)
@ -48,6 +108,7 @@ class LLMLogger:
prompts = prompts.text prompts = prompts.text
logger.debug("Prompts converted to text: %s", prompts) logger.debug("Prompts converted to text: %s", prompts)
elif isinstance(prompts, Dict): elif isinstance(prompts, Dict):
# Convert prompts to a dictionary if they are not in the expected format
logger.debug("Prompts are of type Dict") logger.debug("Prompts are of type Dict")
try: try:
prompts = { prompts = {
@ -76,7 +137,7 @@ class LLMLogger:
except Exception as e: except Exception as e:
logger.error("Error obtaining current time: %s", str(e)) logger.error("Error obtaining current time: %s", str(e))
raise raise
# Extract token usage details from the response
try: try:
token_usage = parsed_reply["usage_metadata"] token_usage = parsed_reply["usage_metadata"]
output_tokens = token_usage["output_tokens"] output_tokens = token_usage["output_tokens"]
@ -86,14 +147,14 @@ class LLMLogger:
except KeyError as e: except KeyError as e:
logger.error("KeyError in parsed_reply structure: %s", str(e)) logger.error("KeyError in parsed_reply structure: %s", str(e))
raise raise
# Extract model details from the response
try: try:
model_name = parsed_reply["response_metadata"]["model_name"] model_name = parsed_reply["response_metadata"]["model_name"]
logger.debug("Model name: %s", model_name) logger.debug("Model name: %s", model_name)
except KeyError as e: except KeyError as e:
logger.error("KeyError in response_metadata: %s", str(e)) logger.error("KeyError in response_metadata: %s", str(e))
raise raise
# Calculate the total cost of the API call
try: try:
prompt_price_per_token = 0.00000015 prompt_price_per_token = 0.00000015
completion_price_per_token = 0.0000006 completion_price_per_token = 0.0000006
@ -108,7 +169,7 @@ class LLMLogger:
"model": model_name, "model": model_name,
"time": current_time, "time": current_time,
"prompts": prompts, "prompts": prompts,
"replies": parsed_reply["content"], # Контент ответа "replies": parsed_reply["content"], # Response content
"total_tokens": total_tokens, "total_tokens": total_tokens,
"input_tokens": input_tokens, "input_tokens": input_tokens,
"output_tokens": output_tokens, "output_tokens": output_tokens,
@ -118,7 +179,7 @@ class LLMLogger:
except KeyError as e: except KeyError as e:
logger.error("Error creating log entry: missing key %s in parsed_reply", str(e)) logger.error("Error creating log entry: missing key %s in parsed_reply", str(e))
raise raise
# Write the log entry to the log file in JSON format
try: try:
with open(calls_log, "a", encoding="utf-8") as f: 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)
@ -130,17 +191,18 @@ class LLMLogger:
class LoggerChatModel: class LoggerChatModel:
def __init__(self, llm: ChatOpenAI): def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]):
logger.debug("Initializing LoggerChatModel with LLM: %s", llm) logger.debug("Initializing LoggerChatModel with LLM: %s", llm)
self.llm = llm self.llm = llm
logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm) logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm)
def __call__(self, messages: List[Dict[str, str]]) -> str: def __call__(self, messages: List[Dict[str, str]]) -> str:
# Call the LLM with the provided messages and log the response.
logger.debug("Entering __call__ method with messages: %s", messages) logger.debug("Entering __call__ method with messages: %s", messages)
while True: while True:
try: try:
logger.debug("Attempting to call the LLM with messages") logger.debug("Attempting to call the LLM with messages")
reply = self.llm(messages) # Вызов LLM reply = self.llm(messages)
logger.debug("LLM response received: %s", reply) logger.debug("LLM response received: %s", reply)
parsed_reply = self.parse_llmresult(reply) parsed_reply = self.parse_llmresult(reply)
@ -180,6 +242,8 @@ class LoggerChatModel:
continue continue
def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]:
# Parse the LLM result into a structured format.
logger.debug("Parsing LLM result: %s", llmresult) logger.debug("Parsing LLM result: %s", llmresult)
try: try:
@ -218,10 +282,9 @@ class LoggerChatModel:
class GPTAnswerer: class GPTAnswerer:
def __init__(self, openai_api_key): def __init__(self, config, llm_api_key):
self.llm_cheap = LoggerChatModel( self.ai_adapter = AIAdapter(config, llm_api_key)
ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4) self.llm_cheap = LoggerChatModel(self.ai_adapter)
)
logger.debug("GPTAnswerer initialized with API key") logger.debug("GPTAnswerer initialized with API key")
@property @property
@ -246,6 +309,7 @@ class GPTAnswerer:
@staticmethod @staticmethod
def _preprocess_template_string(template: str) -> str: def _preprocess_template_string(template: str) -> str:
# Preprocess a template string to remove unnecessary indentation.
logger.debug("Preprocessing template string") logger.debug("Preprocessing template string")
return textwrap.dedent(template) return textwrap.dedent(template)
@ -279,6 +343,7 @@ class GPTAnswerer:
return prompt | self.llm_cheap | StrOutputParser() return prompt | self.llm_cheap | StrOutputParser()
def answer_question_textual_wide_range(self, question: str) -> str: def answer_question_textual_wide_range(self, question: str) -> str:
# Define chains for each section of the resume
logger.debug("Answering textual question: %s", question) logger.debug("Answering textual question: %s", question)
chains = { chains = {
"personal_information": self._create_chain(strings.personal_information_template), "personal_information": self._create_chain(strings.personal_information_template),
@ -387,7 +452,11 @@ class GPTAnswerer:
chain = prompt | self.llm_cheap | StrOutputParser() chain = prompt | self.llm_cheap | StrOutputParser()
output = chain.invoke({"question": question}) output = chain.invoke({"question": question})
logger.debug("Section determined from question: %s", output) logger.debug("Section determined from question: %s", output)
section_name = output.lower().replace(" ", "_") match = re.search(r"(Personal information|Self Identification|Legal Authorization|Work Preferences|Education Details|Experience Details|Projects|Availability|Salary Expectations|Certifications|Languages|Interests|Cover letter)", output, re.IGNORECASE)
if not match:
raise ValueError("Could not extract section name from the response.")
section_name = match.group(1).lower().replace(" ", "_")
if section_name == "cover_letter": if section_name == "cover_letter":
chain = chains.get(section_name) chain = chains.get(section_name)
output = chain.invoke({"resume": self.resume, "job_description": self.job_description}) output = chain.invoke({"resume": self.resume, "job_description": self.job_description})
@ -442,6 +511,7 @@ class GPTAnswerer:
return best_option return best_option
def resume_or_cover(self, phrase: str) -> str: def resume_or_cover(self, phrase: str) -> str:
# Define the prompt template
logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase) logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase)
prompt_template = """ 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 the word 'upload', consider it as 'cover'. Do not provide any additional information or explanations. 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 the word 'upload', consider it as 'cover'. Do not provide any additional information or explanations.

View file

@ -8,7 +8,6 @@ import time
import traceback import traceback
from datetime import date from datetime import date
from typing import List, Optional, Any, Tuple from typing import List, Optional, Any, Tuple
from httpx import HTTPStatusError from httpx import HTTPStatusError
from openai import RateLimitError from openai import RateLimitError
from reportlab.lib.pagesizes import letter from reportlab.lib.pagesizes import letter
@ -175,7 +174,6 @@ class LinkedInEasyApplier:
except Exception as e: except Exception as e:
logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}")
# Обновление страницы после первой неудачной попытки
if attempt == 0: if attempt == 0:
logger.debug("Refreshing page to retry finding 'Easy Apply' button") logger.debug("Refreshing page to retry finding 'Easy Apply' button")
self.driver.refresh() self.driver.refresh()
@ -530,8 +528,7 @@ class LinkedInEasyApplier:
self._enter_text(text_field, existing_answer['answer']) self._enter_text(text_field, existing_answer['answer'])
logger.debug("Entered existing textbox answer.") logger.debug("Entered existing textbox answer.")
# Нажать "Вниз" и "Enter" для выбора первого элемента в выпадающем списке time.sleep(1)
time.sleep(1) # Ожидание появления выпадающего списка
text_field.send_keys(Keys.ARROW_DOWN) text_field.send_keys(Keys.ARROW_DOWN)
text_field.send_keys(Keys.ENTER) text_field.send_keys(Keys.ENTER)
logger.debug("Selected first option from the dropdown.") logger.debug("Selected first option from the dropdown.")
@ -541,8 +538,7 @@ class LinkedInEasyApplier:
self._enter_text(text_field, answer) self._enter_text(text_field, answer)
logger.debug("Entered new textbox answer and saved it to JSON.") logger.debug("Entered new textbox answer and saved it to JSON.")
# Нажать "Вниз" и "Enter" для выбора первого элемента в выпадающем списке time.sleep(1)
time.sleep(1) # Ожидание появления выпадающего списка
text_field.send_keys(Keys.ARROW_DOWN) text_field.send_keys(Keys.ARROW_DOWN)
text_field.send_keys(Keys.ENTER) text_field.send_keys(Keys.ENTER)
logger.debug("Selected first option from the dropdown.") logger.debug("Selected first option from the dropdown.")

View file

@ -47,6 +47,7 @@ class LinkedInJobManager:
self.title_blacklist = parameters.get('titleBlacklist', []) or [] self.title_blacklist = parameters.get('titleBlacklist', []) or []
self.positions = parameters.get('positions', []) self.positions = parameters.get('positions', [])
self.locations = parameters.get('locations', []) self.locations = parameters.get('locations', [])
self.apply_once_at_company = parameters.get('applyOnceAtCompany', False)
self.base_search_url = self.get_base_search_url(parameters) self.base_search_url = self.get_base_search_url(parameters)
self.seen_jobs = [] self.seen_jobs = []
resume_path = parameters.get('uploads', {}).get('resume', None) resume_path = parameters.get('uploads', {}).get('resume', None)
@ -86,7 +87,6 @@ class LinkedInJobManager:
time.sleep(random.uniform(1.5, 3.5)) time.sleep(random.uniform(1.5, 3.5))
utils.printyellow("Starting the application process for this page...") utils.printyellow("Starting the application process for this page...")
# Проверка на наличие вакансий на странице
try: try:
jobs = self.get_jobs_from_page() jobs = self.get_jobs_from_page()
if not jobs: if not jobs:
@ -94,7 +94,7 @@ class LinkedInJobManager:
break break
except Exception as e: except Exception as e:
logger.error(f"Failed to retrieve jobs: {e}") logger.error(f"Failed to retrieve jobs: {e}")
break # Выходим из цикла, если не удалось получить вакансии break
try: try:
self.apply_jobs() self.apply_jobs()
@ -136,40 +136,32 @@ class LinkedInJobManager:
def get_jobs_from_page(self): def get_jobs_from_page(self):
"""
Функция для получения списка вакансий на текущей странице.
Если вакансии не найдены, возвращает пустой список.
"""
try: try:
# Проверка на отсутствие вакансий
no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') 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(): if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower():
utils.printyellow("No matching jobs found on this page.") utils.printyellow("No matching jobs found on this page.")
logger.debug("No matching jobs found on this page, skipping.") logger.debug("No matching jobs found on this page, skipping.")
return [] # Возвращаем пустой список, если нет вакансий return []
except NoSuchElementException: except NoSuchElementException:
pass # Если элемент не найден, продолжаем поиск вакансий pass
# Поиск контейнера результатов с вакансиями
try: try:
job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list")
utils.scroll_slow(self.driver, job_results) utils.scroll_slow(self.driver, job_results)
utils.scroll_slow(self.driver, job_results, step=300, reverse=True) 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') 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: if not job_list_elements:
utils.printyellow("No job class elements found on page.") utils.printyellow("No job class elements found on page.")
logger.debug("No job class elements found on page, skipping.") logger.debug("No job class elements found on page, skipping.")
return [] return []
# Возвращаем список найденных вакансий
return job_list_elements return job_list_elements
except NoSuchElementException: except NoSuchElementException:
logger.debug("No job results found on the page.") logger.debug("No job results found on the page.")
return [] # Если не найден контейнер с результатами, возвращаем пустой список return []
except Exception as e: except Exception as e:
logger.error(f"Error while fetching job elements: {e}") logger.error(f"Error while fetching job elements: {e}")
@ -181,9 +173,9 @@ class LinkedInJobManager:
if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower():
utils.printyellow("No matching jobs found on this page, moving to next.") utils.printyellow("No matching jobs found on this page, moving to next.")
logger.debug("No matching jobs found on this page, skipping") logger.debug("No matching jobs found on this page, skipping")
return # Выход из метода, если нет больше подходящих вакансий return
except NoSuchElementException: except NoSuchElementException:
pass # Если элемент не найден, просто продолжаем pass
job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list")
utils.scroll_slow(self.driver, job_results) utils.scroll_slow(self.driver, job_results)
@ -192,7 +184,7 @@ class LinkedInJobManager:
if not job_list_elements: if not job_list_elements:
utils.printyellow("No job class elements found on page, moving to next page.") utils.printyellow("No job class elements found on page, moving to next page.")
logger.debug("No job class elements found on page, skipping") logger.debug("No job class elements found on page, skipping")
return # Выход из метода, если нет вакансий на странице return
job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements] job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements]
for job in job_list: for job in job_list:
if self.is_blacklisted(job.title, job.company, job.link): if self.is_blacklisted(job.title, job.company, job.link):
@ -200,6 +192,12 @@ class LinkedInJobManager:
logger.debug("Job blacklisted: %s at %s", job.title, job.company) logger.debug("Job blacklisted: %s at %s", job.title, job.company)
self.write_to_file(job, "skipped") self.write_to_file(job, "skipped")
continue continue
if self.is_already_applied_to_job(job.title, job.company, job.link):
self.write_to_file(job, "skipped")
continue
if self.is_already_applied_to_company(job.company):
self.write_to_file(job, "skipped")
continue
try: try:
if job.apply_method not in {"Continue", "Applied", "Apply"}: if job.apply_method not in {"Continue", "Applied", "Apply"}:
self.easy_applier_component.job_apply(job) self.easy_applier_component.job_apply(job)
@ -301,6 +299,33 @@ class LinkedInJobManager:
title_blacklisted = any(word in job_title_words for word in self.title_blacklist) title_blacklisted = any(word in job_title_words for word in self.title_blacklist)
company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist) company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist)
link_seen = link in self.seen_jobs link_seen = link in self.seen_jobs
is_blacklisted = title_blacklisted or company_blacklisted or link_seen is_blacklisted = title_blacklisted or company_blacklisted or link_seen
logger.debug("Job blacklisted status: %s", is_blacklisted) logger.debug("Job blacklisted status: %s", is_blacklisted)
return is_blacklisted return is_blacklisted
def is_already_applied_to_job(self, job_title, company, link):
link_seen = link in self.seen_jobs
if link_seen:
utils.printyellow(f"Already applied to job: {job_title} at {company}, skipping...")
return link_seen
def is_already_applied_to_company(self, company):
if not self.apply_once_at_company:
return False
output_files = ["success.json"]
for file_name in output_files:
file_path = self.output_file_directory / file_name
if file_path.exists():
with open(file_path, 'r', encoding='utf-8') as f:
try:
existing_data = json.load(f)
for applied_job in existing_data:
if applied_job['company'].strip().lower() == company.strip().lower():
utils.printyellow(f"Already applied at {company} (once per company policy), skipping...")
return True
except json.JSONDecodeError:
continue
return False