├── .devcontainer └── devcontainer.json ├── .gitignore ├── README.md ├── agents.py ├── app.py ├── chains.py ├── custom_callback_handler.py ├── data_loader.py ├── dummy_resume.pdf ├── llms.py ├── members.py ├── multiagent.png ├── prompts.py ├── requirements.txt ├── schemas.py ├── search.py ├── tools.py └── utils.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 4 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": [ 8 | "README.md", 9 | "app.py" 10 | ] 11 | }, 12 | "vscode": { 13 | "settings": {}, 14 | "extensions": [ 15 | "ms-python.python", 16 | "ms-python.vscode-pylance" 17 | ] 18 | } 19 | }, 20 | "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y 4 | 5 | **Author**: [Aman Varyani](https://medium.com/@amanvaryani1910) 6 | **Linkedin**: [Aman Varyani](https://www.linkedin.com/in/aman-varyani-885725181/) 7 | 8 | Welcome to the GenAI Career Assistant, a powerful tool designed to revolutionize the job search process using cutting-edge AI technology. This project leverages a multi-agent architecture to provide personalized career guidance, making job hunting more efficient and tailored to individual needs. 9 | 10 | ## Table of Contents 11 | 12 | - [Demo](#demo) 13 | - [Why It's Needed](#why-its-needed) 14 | - [Features](#features) 15 | - [Architecture Overview](#architecture-overview) 16 | - [Key Components](#key-components) 17 | - [Technologies Used](#technologies-used) 18 | - [Installation](#installation) 19 | - [Usage](#usage) 20 | - [Future Improvements](#future-improvements) 21 | - [Contributing](#contributing) 22 | - [License](#license) 23 | 24 | ## Demo 25 | https://github.com/user-attachments/assets/f1e191ae-19c4-48a0-b24f-dfd59bd9240a 26 | 27 | 28 | ## Why It's Needed 29 | 30 | In today's rapidly evolving job market, finding the right job and standing out from the competition can be challenging. The GenAI Career Assistant addresses this by leveraging AI to: 31 | 32 | - Tailor job searches to your industry, experience, and location. 33 | - Generate standout cover letters that highlight your strengths. 34 | - Provide detailed insights into potential employers. 35 | - Streamline the job application process through an intelligent, multi-agent system. 36 | 37 | ## Features 38 | 39 | - **Personalized Job Search:** Automatically find job listings that match your criteria. 40 | - **Custom Cover Letters:** Generate cover letters tailored to specific job applications. 41 | - **Company Research:** Gather and present key information about potential employers. 42 | - **Resume Analysis:** Extract and analyze key information from your resume to optimize job matches. 43 | - **Interactive UI:** Easy-to-use interface built with Streamlit for a seamless user experience. 44 | 45 | ## Architecture Overview 46 | 47 | The GenAI Career Assistant is built on a Supervisor Multi-Agent Architecture. Here's how it works: 48 | 49 | - **Supervisor:** Manages the overall workflow, deciding which agent to invoke next. 50 | - **JobSearcher:** Handles job search queries and retrieves relevant listings. 51 | - **ResumeAnalyzer:** Extracts and analyzes information from uploaded resumes. 52 | - **CoverLetterGenerator:** Crafts customized cover letters based on resume and job details. 53 | - **WebResearcher:** Performs web searches to gather relevant company information. 54 | - **ChatBot:** Manages general queries and provides conversational responses. 55 | 56 | ## Key Components 57 | 58 | - **Agent Creation and Configuration:** A common function is used to set up agents with specific tools and prompts. 59 | - **Specialized Tools:** Custom tools enhance the agents' capabilities, such as job search tools, resume extractors, and web search tools. 60 | - **Streamlit UI:** The user interface is designed to be intuitive and responsive, facilitating interaction with the assistant. 61 | 62 | ## Technologies Used 63 | 64 | - **LangGraph:** For creating and managing multi-agent workflows. 65 | - **Streamlit:** For building the user interface. 66 | - **OpenAI API:** For leveraging large language models (LLMs). 67 | - **SerperClient and FireCrawlClient:** For web search and scraping capabilities. 68 | 69 | ## Installation 70 | 71 | 1. Clone the repository: 72 | ```bash 73 | git clone https://github.com/amanv1906/GENAI-CareerAssistant-Multiagent.git 74 | cd GENAI-CareerAssistant-Multiagent 75 | ``` 76 | 77 | 2. Install the required packages: 78 | ```bash 79 | pip install -r requirements.txt 80 | ``` 81 | 82 | 3. Set up environment variables by creating a `.streamlit/secrets.toml` file: 83 | ```toml 84 | OPENAI_API_KEY = "your-openai-api-key" 85 | LANGCHAIN_API_KEY = "" # if you want to trace using langsmith" 86 | LANGCHAIN_TRACING_V2 = "true" 87 | LANGCHAIN_PROJECT = "JOB_SEARCH_AGENT" 88 | GROQ_API_KEY = "API key of groq" 89 | SERPER_API_KEY = "serper API key" 90 | FIRECRAWL_API_KEY = "firecrawl API key" 91 | LINKEDIN_JOB_SEARCH = "linkedin_api" # only if you want to use python linkedin-api package 92 | LINKEDIN_EMAIL = "" # if you have enabled linkedin job search then both password and email are mandatory. 93 | LINKEDIN_PASS = "" 94 | ``` 95 | 96 | 4. Run the Streamlit app: 97 | ```bash 98 | streamlit run app.py 99 | ``` 100 | 101 | ## Usage 102 | 103 | 1. **Upload Your Resume:** Start by uploading your resume in PDF format. 104 | 2. **Enter Your Query:** Use the chat interface to ask questions or request specific tasks (e.g., "Find jobs in data science"). 105 | 3. **Interact with the Assistant:** The assistant will guide you through job searches, resume analysis, and cover letter generation. 106 | 4. **Download Results:** Save the generated cover letters or other documents as needed. 107 | 108 | ## Future Improvements 109 | 110 | - **Job Application Integration:** Streamline the application process by integrating directly with job portals. 111 | - **Enhanced User Interface:** Improve the UI/UX with more interactive and dynamic elements. 112 | 113 | ## Contributing 114 | 115 | Contributions are welcome! Please feel free to submit issues, fork the repository, and send pull requests. 116 | 117 | ## License 118 | 119 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 120 | 121 | --- 122 | -------------------------------------------------------------------------------- /agents.py: -------------------------------------------------------------------------------- 1 | from typing import Any, TypedDict 2 | from langchain.agents import ( 3 | AgentExecutor, 4 | create_openai_tools_agent, 5 | ) 6 | from langchain.chat_models import init_chat_model 7 | from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder 8 | from langchain_core.messages import BaseMessage, HumanMessage, AIMessage 9 | from langchain_openai import ChatOpenAI 10 | 11 | from langgraph.graph import StateGraph, END 12 | from dotenv import load_dotenv 13 | from chains import get_finish_chain, get_supervisor_chain 14 | from tools import ( 15 | get_job_search_tool, 16 | ResumeExtractorTool, 17 | generate_letter_for_specific_job, 18 | get_google_search_results, 19 | save_cover_letter_for_specific_job, 20 | scrape_website, 21 | ) 22 | from prompts import ( 23 | get_search_agent_prompt_template, 24 | get_analyzer_agent_prompt_template, 25 | researcher_agent_prompt_template, 26 | get_generator_agent_prompt_template, 27 | ) 28 | 29 | load_dotenv() 30 | 31 | 32 | def create_agent(llm: ChatOpenAI, tools: list, system_prompt: str): 33 | """ 34 | Creates an agent using the specified ChatOpenAI model, tools, and system prompt. 35 | 36 | Args: 37 | llm : LLM to be used to create the agent. 38 | tools (list): The list of tools to be given to the worker node. 39 | system_prompt (str): The system prompt to be used in the agent. 40 | 41 | Returns: 42 | AgentExecutor: The executor for the created agent. 43 | """ 44 | # Each worker node will be given a name and some tools. 45 | prompt = ChatPromptTemplate.from_messages( 46 | [ 47 | ( 48 | "system", 49 | system_prompt, 50 | ), 51 | MessagesPlaceholder(variable_name="messages"), 52 | MessagesPlaceholder(variable_name="agent_scratchpad"), 53 | ] 54 | ) 55 | agent = create_openai_tools_agent(llm, tools, prompt) 56 | executor = AgentExecutor(agent=agent, tools=tools) 57 | return executor 58 | 59 | 60 | def supervisor_node(state): 61 | """ 62 | The supervisor node is the main node in the graph. It is responsible for routing to the correct agent. 63 | """ 64 | chat_history = state.get("messages", []) 65 | llm = init_chat_model(**state["config"]) 66 | supervisor_chain = get_supervisor_chain(llm) 67 | if not chat_history: 68 | chat_history.append(HumanMessage(state["user_input"])) 69 | output = supervisor_chain.invoke({"messages": chat_history}) 70 | state["next_step"] = output.next_action 71 | state["messages"] = chat_history 72 | return state 73 | 74 | 75 | def job_search_node(state): 76 | """ 77 | This Node is responsible for searching for jobs from linkedin or any other job search engine. 78 | Tools: Job Search Tool 79 | """ 80 | llm = init_chat_model(**state["config"]) 81 | search_agent = create_agent( 82 | llm, [get_job_search_tool()], get_search_agent_prompt_template() 83 | ) 84 | chat_history = state.get("messages", []) 85 | state["callback"].write_agent_name("JobSearcher Agent 💼") 86 | output = search_agent.invoke( 87 | {"messages": chat_history}, {"callbacks": [state["callback"]]} 88 | ) 89 | state["messages"].append( 90 | HumanMessage(content=output.get("output"), name="JobSearcher") 91 | ) 92 | return state 93 | 94 | 95 | def resume_analyzer_node(state): 96 | """ 97 | Resume analyzer node will analyze the resume and return the output. 98 | Tools: Resume Extractor 99 | """ 100 | llm = init_chat_model(**state["config"]) 101 | analyzer_agent = create_agent( 102 | llm, [ResumeExtractorTool()], get_analyzer_agent_prompt_template() 103 | ) 104 | state["callback"].write_agent_name("ResumeAnalyzer Agent 📄") 105 | output = analyzer_agent.invoke( 106 | {"messages": state["messages"]}, {"callbacks": [state["callback"]]} 107 | ) 108 | state["messages"].append( 109 | HumanMessage(content=output.get("output"), name="ResumeAnalyzer") 110 | ) 111 | return state 112 | 113 | 114 | def cover_letter_generator_node(state): 115 | """ 116 | Node which handles the generation of cover letters. 117 | Tools: Cover Letter Generator, Cover Letter Saver 118 | """ 119 | llm = init_chat_model(**state["config"]) 120 | generator_agent = create_agent( 121 | llm, 122 | [ 123 | generate_letter_for_specific_job, 124 | save_cover_letter_for_specific_job, 125 | ResumeExtractorTool(), 126 | ], 127 | get_generator_agent_prompt_template(), 128 | ) 129 | 130 | state["callback"].write_agent_name("CoverLetterGenerator Agent ✍️") 131 | output = generator_agent.invoke( 132 | {"messages": state["messages"]}, {"callbacks": [state["callback"]]} 133 | ) 134 | state["messages"].append( 135 | HumanMessage( 136 | content=output.get("output"), 137 | name="CoverLetterGenerator", 138 | ) 139 | ) 140 | 141 | 142 | def web_research_node(state): 143 | """ 144 | Node which handles the web research. 145 | Tools: Google Search, Web Scraper 146 | """ 147 | llm = init_chat_model(**state["config"]) 148 | research_agent = create_agent( 149 | llm, 150 | [get_google_search_results, scrape_website], 151 | researcher_agent_prompt_template(), 152 | ) 153 | state["callback"].write_agent_name("WebResearcher Agent 🔍") 154 | output = research_agent.invoke( 155 | {"messages": state["messages"]}, {"callbacks": [state["callback"]]} 156 | ) 157 | state["messages"].append( 158 | HumanMessage(content=output.get("output"), name="WebResearcher") 159 | ) 160 | return state 161 | 162 | 163 | def chatbot_node(state): 164 | llm = init_chat_model(**state["config"]) 165 | finish_chain = get_finish_chain(llm) 166 | state["callback"].write_agent_name("ChatBot Agent 🤖") 167 | output = finish_chain.invoke({"messages": state["messages"]}) 168 | state["messages"].append(AIMessage(content=output.content, name="ChatBot")) 169 | return state 170 | 171 | 172 | def define_graph(): 173 | """ 174 | Defines and returns a graph representing the workflow of job search agent. 175 | Returns: 176 | graph (StateGraph): The compiled graph representing the workflow. 177 | """ 178 | workflow = StateGraph(AgentState) 179 | workflow.add_node("ResumeAnalyzer", resume_analyzer_node) 180 | workflow.add_node("JobSearcher", job_search_node) 181 | workflow.add_node("CoverLetterGenerator", cover_letter_generator_node) 182 | workflow.add_node("Supervisor", supervisor_node) 183 | workflow.add_node("WebResearcher", web_research_node) 184 | workflow.add_node("ChatBot", chatbot_node) 185 | 186 | members = [ 187 | "ResumeAnalyzer", 188 | "CoverLetterGenerator", 189 | "JobSearcher", 190 | "WebResearcher", 191 | "ChatBot", 192 | ] 193 | workflow.set_entry_point("Supervisor") 194 | 195 | for member in members: 196 | # We want our workers to ALWAYS "report back" to the supervisor when done 197 | workflow.add_edge(member, "Supervisor") 198 | 199 | conditional_map = {k: k for k in members} 200 | conditional_map["Finish"] = END 201 | 202 | workflow.add_conditional_edges( 203 | "Supervisor", lambda x: x["next_step"], conditional_map 204 | ) 205 | 206 | graph = workflow.compile() 207 | return graph 208 | 209 | 210 | # The agent state is the input to each node in the graph 211 | class AgentState(TypedDict): 212 | user_input: str 213 | messages: list[BaseMessage] 214 | next_step: str 215 | config: dict 216 | callback: Any 217 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypeVar 2 | import os 3 | import inspect 4 | import streamlit as st 5 | import streamlit_analytics2 as streamlit_analytics 6 | from dotenv import load_dotenv 7 | from streamlit_chat import message 8 | from streamlit_pills import pills 9 | from streamlit.runtime.scriptrunner import add_script_run_ctx, get_script_run_ctx 10 | from streamlit.delta_generator import DeltaGenerator 11 | from langchain_community.chat_message_histories import StreamlitChatMessageHistory 12 | from custom_callback_handler import CustomStreamlitCallbackHandler 13 | from agents import define_graph 14 | import shutil 15 | 16 | load_dotenv() 17 | 18 | # Set environment variables from Streamlit secrets or .env 19 | os.environ["LINKEDIN_EMAIL"] = st.secrets.get("LINKEDIN_EMAIL", "") 20 | os.environ["LINKEDIN_PASS"] = st.secrets.get("LINKEDIN_PASS", "") 21 | os.environ["LANGCHAIN_API_KEY"] = st.secrets.get("LANGCHAIN_API_KEY", "") 22 | os.environ["LANGCHAIN_TRACING_V2"] = os.getenv("LANGCHAIN_TRACING_V2") or st.secrets.get("LANGCHAIN_TRACING_V2", "") 23 | os.environ["LANGCHAIN_PROJECT"] = st.secrets.get("LANGCHAIN_PROJECT", "") 24 | os.environ["GROQ_API_KEY"] = st.secrets.get("GROQ_API_KEY", "") 25 | os.environ["SERPER_API_KEY"] = st.secrets.get("SERPER_API_KEY", "") 26 | os.environ["FIRECRAWL_API_KEY"] = st.secrets.get("FIRECRAWL_API_KEY", "") 27 | os.environ["LINKEDIN_SEARCH"] = st.secrets.get("LINKEDIN_JOB_SEARCH", "") 28 | 29 | # Page configuration 30 | st.set_page_config(layout="wide") 31 | st.title("GenAI Career Assistant - 👨‍💼") 32 | st.markdown("[Connect with me on LinkedIn](https://www.linkedin.com/in/aman-varyani-885725181/)") 33 | 34 | streamlit_analytics.start_tracking() 35 | 36 | # Setup directories and paths 37 | temp_dir = "temp" 38 | dummy_resume_path = os.path.abspath("dummy_resume.pdf") 39 | 40 | if not os.path.exists(temp_dir): 41 | os.makedirs(temp_dir) 42 | 43 | # Add dummy resume if it does not exist 44 | if not os.path.exists(dummy_resume_path): 45 | default_resume_path = "path/to/your/dummy_resume.pdf" 46 | shutil.copy(default_resume_path, dummy_resume_path) 47 | 48 | # Sidebar - File Upload 49 | uploaded_document = st.sidebar.file_uploader("Upload Your Resume", type="pdf") 50 | 51 | if not uploaded_document: 52 | uploaded_document = open(dummy_resume_path, "rb") 53 | st.sidebar.write("Using a dummy resume for demonstration purposes. ") 54 | st.sidebar.markdown(f"[View Dummy Resume]({'https://drive.google.com/file/d/1vTdtIPXEjqGyVgUgCO6HLiG9TSPcJ5eM/view?usp=sharing'})", unsafe_allow_html=True) 55 | 56 | bytes_data = uploaded_document.read() 57 | 58 | filepath = os.path.join(temp_dir, "resume.pdf") 59 | with open(filepath, "wb") as f: 60 | f.write(bytes_data) 61 | 62 | st.markdown("**Resume uploaded successfully!**") 63 | 64 | # Sidebar - Service Provider Selection 65 | service_provider = st.sidebar.selectbox( 66 | "Service Provider", 67 | ("groq (llama-3.1-70b-versatile)", "openai"), 68 | ) 69 | streamlit_analytics.stop_tracking() 70 | 71 | # Not to track the key 72 | if service_provider == "openai": 73 | # Sidebar - OpenAI Configuration 74 | api_key_openai = st.sidebar.text_input( 75 | "OpenAI API Key", 76 | st.session_state.get("OPENAI_API_KEY", ""), 77 | type="password", 78 | ) 79 | model_openai = st.sidebar.selectbox( 80 | "OpenAI Model", 81 | ("gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"), 82 | ) 83 | settings = { 84 | "model": model_openai, 85 | "model_provider": "openai", 86 | "temperature": 0.3, 87 | } 88 | st.session_state["OPENAI_API_KEY"] = api_key_openai 89 | os.environ["OPENAI_API_KEY"] = st.session_state["OPENAI_API_KEY"] 90 | 91 | else: 92 | # Toggle visibility for Groq API Key input 93 | if "groq_key_visible" not in st.session_state: 94 | st.session_state["groq_key_visible"] = False 95 | 96 | if st.sidebar.button("Enter Groq API Key (optional)"): 97 | st.session_state["groq_key_visible"] = True 98 | 99 | if st.session_state["groq_key_visible"]: 100 | api_key_groq = st.sidebar.text_input("Groq API Key", type="password") 101 | st.session_state["GROQ_API_KEY"] = api_key_groq 102 | os.environ["GROQ_API_KEY"] = api_key_groq 103 | 104 | settings = { 105 | "model": "llama-3.1-70b-versatile", 106 | "model_provider": "groq", 107 | "temperature": 0.3, 108 | } 109 | 110 | # Sidebar - Service Provider Note 111 | st.sidebar.markdown( 112 | """ 113 | **Note:** \n 114 | This multi-agent system works best with OpenAI. llama 3.1 may not always produce optimal results.\n 115 | Any key provided will not be stored or shared it will be used only for the current session. 116 | """ 117 | ) 118 | st.sidebar.markdown( 119 | """ 120 |
121 | If you like the project, give a 122 | 123 | ⭐ on GitHub 124 | 125 |
126 | """, 127 | unsafe_allow_html=True, 128 | ) 129 | 130 | # Create the agent flow 131 | flow_graph = define_graph() 132 | message_history = StreamlitChatMessageHistory() 133 | 134 | # Initialize session state variables 135 | if "active_option_index" not in st.session_state: 136 | st.session_state["active_option_index"] = None 137 | if "interaction_history" not in st.session_state: 138 | st.session_state["interaction_history"] = [] 139 | if "response_history" not in st.session_state: 140 | st.session_state["response_history"] = ["Hello! How can I assist you today?"] 141 | if "user_query_history" not in st.session_state: 142 | st.session_state["user_query_history"] = ["Hi there! 👋"] 143 | 144 | # Containers for the chat interface 145 | conversation_container = st.container() 146 | input_section = st.container() 147 | 148 | # Define functions used above 149 | def initialize_callback_handler(main_container: DeltaGenerator): 150 | V = TypeVar("V") 151 | 152 | def wrap_function(func: Callable[..., V]) -> Callable[..., V]: 153 | context = get_script_run_ctx() 154 | 155 | def wrapped(*args, **kwargs) -> V: 156 | add_script_run_ctx(ctx=context) 157 | return func(*args, **kwargs) 158 | 159 | return wrapped 160 | 161 | streamlit_callback_instance = CustomStreamlitCallbackHandler( 162 | parent_container=main_container 163 | ) 164 | 165 | for method_name, method in inspect.getmembers( 166 | streamlit_callback_instance, predicate=inspect.ismethod 167 | ): 168 | setattr(streamlit_callback_instance, method_name, wrap_function(method)) 169 | 170 | return streamlit_callback_instance 171 | 172 | def execute_chat_conversation(user_input, graph): 173 | callback_handler_instance = initialize_callback_handler(st.container()) 174 | callback_handler = callback_handler_instance 175 | try: 176 | output = graph.invoke( 177 | { 178 | "messages": list(message_history.messages) + [user_input], 179 | "user_input": user_input, 180 | "config": settings, 181 | "callback": callback_handler, 182 | }, 183 | {"recursion_limit": 30}, 184 | ) 185 | message_output = output.get("messages")[-1] 186 | messages_list = output.get("messages") 187 | message_history.clear() 188 | message_history.add_messages(messages_list) 189 | 190 | except Exception as exc: 191 | return ":( Sorry, Some error occurred. Can you please try again?" 192 | return message_output.content 193 | 194 | # Clear Chat functionality 195 | if st.button("Clear Chat"): 196 | st.session_state["user_query_history"] = [] 197 | st.session_state["response_history"] = [] 198 | message_history.clear() 199 | st.rerun() # Refresh the app to reflect the cleared chat 200 | 201 | # for tracking the query. 202 | streamlit_analytics.start_tracking() 203 | 204 | # Display chat interface 205 | with input_section: 206 | options = [ 207 | "Identify top trends in the tech industry relevant to gen ai", 208 | "Find emerging technologies and their potential impact on job opportunities", 209 | "Summarize my resume", 210 | "Create a career path visualization based on my skills and interests from my resume", 211 | "GenAI Jobs at Microsoft", 212 | "Job Search GenAI jobs in India.", 213 | "Analyze my resume and suggest a suitable job role and search for relevant job listings", 214 | "Generate a cover letter for my resume.", 215 | ] 216 | icons = ["🔍", "🌐", "📝", "📈", "💼", "🌟", "✉️", "🧠 "] 217 | 218 | selected_query = pills( 219 | "Pick a question for query:", 220 | options, 221 | clearable=None, # type: ignore 222 | icons=icons, 223 | index=st.session_state["active_option_index"], 224 | key="pills", 225 | ) 226 | if selected_query: 227 | st.session_state["active_option_index"] = options.index(selected_query) 228 | 229 | # Display text input form 230 | with st.form(key="query_form", clear_on_submit=True): 231 | user_input_query = st.text_input( 232 | "Query:", 233 | value=(selected_query if selected_query else "Detail analysis of latest layoff news India?"), 234 | placeholder="📝 Write your query or select from the above", 235 | key="input", 236 | ) 237 | submit_query_button = st.form_submit_button(label="Send") 238 | 239 | if submit_query_button: 240 | if not uploaded_document: 241 | st.error("Please upload your resume before submitting a query.") 242 | 243 | elif service_provider == "openai" and not st.session_state["OPENAI_API_KEY"]: 244 | st.error("Please enter your OpenAI API key before submitting a query.") 245 | 246 | elif user_input_query: 247 | # Process the query as usual if resume is uploaded 248 | chat_output = execute_chat_conversation(user_input_query, flow_graph) 249 | st.session_state["user_query_history"].append(user_input_query) 250 | st.session_state["response_history"].append(chat_output) 251 | st.session_state["last_input"] = user_input_query # Save the latest input 252 | st.session_state["active_option_index"] = None 253 | 254 | # Display chat history 255 | if st.session_state["response_history"]: 256 | with conversation_container: 257 | for i in range(len(st.session_state["response_history"])): 258 | message( 259 | st.session_state["user_query_history"][i], 260 | is_user=True, 261 | key=str(i) + "_user", 262 | avatar_style="fun-emoji", 263 | ) 264 | message( 265 | st.session_state["response_history"][i], 266 | key=str(i), 267 | avatar_style="bottts", 268 | ) 269 | 270 | streamlit_analytics.stop_tracking() 271 | -------------------------------------------------------------------------------- /chains.py: -------------------------------------------------------------------------------- 1 | from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder 2 | from langchain_core.language_models.chat_models import BaseChatModel 3 | from typing import List 4 | 5 | from members import get_team_members_details 6 | from prompts import get_supervisor_prompt_template, get_finish_step_prompt 7 | from schemas import RouteSchema 8 | 9 | 10 | def get_supervisor_chain(llm: BaseChatModel): 11 | """ 12 | Returns a supervisor chain that manages a conversation between workers. 13 | 14 | The supervisor chain is responsible for managing a conversation between a group 15 | of workers. It prompts the supervisor to select the next worker to act, and 16 | each worker performs a task and responds with their results and status. The 17 | conversation continues until the supervisor decides to finish. 18 | 19 | Returns: 20 | supervisor_chain: A chain of prompts and functions that handle the conversation 21 | between the supervisor and workers. 22 | """ 23 | 24 | team_members = get_team_members_details() 25 | 26 | # Generate the formatted string 27 | formatted_string = "" 28 | for i, member in enumerate(team_members): 29 | formatted_string += ( 30 | f"**{i+1} {member['name']}**\nRole: {member['description']}\n\n" 31 | ) 32 | 33 | # Remove the trailing new line 34 | formatted_members_string = formatted_string.strip() 35 | system_prompt = get_supervisor_prompt_template() 36 | 37 | options = [member["name"] for member in team_members] 38 | prompt = ChatPromptTemplate.from_messages( 39 | [ 40 | ("system", system_prompt), 41 | MessagesPlaceholder(variable_name="messages"), 42 | ( 43 | "system", 44 | """ 45 | 46 | Few steps to follow: 47 | - Don't overcomplicate the conversation. 48 | - If the user asked something to search on web then get the information and show it. 49 | - If the user asked to analyze resume then just analyze it, don't be oversmart and do something else. 50 | - Don't call chatbot agent if user is not asking from the above conversation. 51 | 52 | Penalty point will be given if you are not following the above steps. 53 | Given the conversation above, who should act next? 54 | "Or should we FINISH? Select one of: {options}. 55 | Do only what is asked, and do not deviate from the instructions. Don't hallucinate or 56 | make up information.""", 57 | ), 58 | ] 59 | ).partial(options=str(options), members=formatted_members_string) 60 | 61 | supervisor_chain = prompt | llm.with_structured_output(RouteSchema) 62 | 63 | return supervisor_chain 64 | 65 | 66 | def get_finish_chain(llm: BaseChatModel): 67 | """ 68 | If the supervisor decides to finish the conversation, this chain is executed. 69 | """ 70 | system_prompt = get_finish_step_prompt() 71 | prompt = ChatPromptTemplate.from_messages( 72 | [ 73 | MessagesPlaceholder(variable_name="messages"), 74 | ("system", system_prompt), 75 | ] 76 | ) 77 | finish_chain = prompt | llm 78 | return finish_chain 79 | -------------------------------------------------------------------------------- /custom_callback_handler.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from langchain_community.callbacks import StreamlitCallbackHandler 3 | from streamlit.external.langchain.streamlit_callback_handler import ( 4 | StreamlitCallbackHandler, 5 | LLMThought, 6 | ) 7 | from langchain.schema import AgentAction 8 | 9 | 10 | class CustomStreamlitCallbackHandler(StreamlitCallbackHandler): 11 | def write_agent_name(self, name: str): 12 | self._parent_container.write(name) 13 | -------------------------------------------------------------------------------- /data_loader.py: -------------------------------------------------------------------------------- 1 | from docx import Document 2 | from langchain_community.document_loaders import PyMuPDFLoader 3 | 4 | 5 | def load_resume(file_path): 6 | """ 7 | Load the content of a CV file. 8 | 9 | Parameters: 10 | file (str): The path to the CV file. 11 | 12 | Returns: 13 | str: The content of the CV file. 14 | """ 15 | loader = PyMuPDFLoader(file_path) 16 | pages = loader.load() 17 | page_content = "" 18 | for page in pages: 19 | page_content += page.page_content 20 | return page_content 21 | 22 | 23 | def write_cover_letter_to_doc(text, filename="temp/cover_letter.docx"): 24 | """ 25 | Writes the given text as a cover letter to a Word document. 26 | 27 | Parameters: 28 | text (str): The text content of the cover letter. 29 | filename (str): The filename and path where the document will be saved. Default is "temp/cover_letter.docx". 30 | 31 | Returns: 32 | str: The filename and path of the saved document. 33 | """ 34 | doc = Document() 35 | paragraphs = text.split("\n") 36 | # Add each paragraph to the document 37 | for para in paragraphs: 38 | doc.add_paragraph(para) 39 | # Save the document to the specified file 40 | doc.save(filename) 41 | return filename 42 | -------------------------------------------------------------------------------- /dummy_resume.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amanv1906/GENAI-CareerAssistant-Multiagent/9d37cbf7617275df6549bd3e2390900ebc496f81/dummy_resume.pdf -------------------------------------------------------------------------------- /llms.py: -------------------------------------------------------------------------------- 1 | #define LLMs 2 | from langchain_openai import ChatOpenAI 3 | from langchain_groq import ChatGroq 4 | import os 5 | 6 | def load_llm(llm_name): #gpt-4-0125-preview gpt-4-turbo-2024-04-09 7 | if llm_name=='openai': 8 | llm = ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=os.environ["OPENAI_API_KEY"], temperature = 0.1, streaming=True) # type: ignore 9 | if llm_name=='groq': 10 | llm = ChatGroq(temperature=0.2, groq_api_key=os.environ["GROQ_API_KEY"], model_name="llama3-70b-8192" ) # type: ignore #temperature = 0.1 mixtral-8x7b-32768 llama3-70b-8192 11 | if llm_name=="llama3": 12 | llm = ChatOpenAI(model="llama3", base_url="http://localhost:11434/v1", temperature = 0.0) 13 | return llm -------------------------------------------------------------------------------- /members.py: -------------------------------------------------------------------------------- 1 | def get_team_members_details() -> dict: 2 | """ 3 | Returns a dictionary containing details of team members. 4 | 5 | Each team member is represented as a dictionary with the following keys: 6 | - name: The name of the team member. 7 | - description: A brief description of the team member's role and responsibilities. 8 | 9 | Returns: 10 | A dictionary containing details of team members. 11 | """ 12 | members_dict = [ 13 | { 14 | "name": "ResumeAnalyzer", 15 | "description": "Responsible for analyzing resumes to extract key information.", 16 | }, 17 | { 18 | "name": "CoverLetterGenerator", 19 | "description": "Specializes in creating and optimizing cover letters tailored to job descriptions. Highlights the candidate's strengths and ensures the cover letter aligns with the requirements of the position.", 20 | }, 21 | { 22 | "name": "JobSearcher", 23 | "description": "Conducts job searches based on specified criteria such as industry, location, and job title.", 24 | }, 25 | { 26 | "name": "WebResearcher", 27 | "description": "Conducts online research to gather information from web.", 28 | }, 29 | { 30 | "name": "ChatBot", 31 | "description": "If user is asking something to format or he want to get some information from the messages." 32 | }, 33 | { 34 | "name": "Finish", 35 | "description": "Represents the end of the workflow.", 36 | }, 37 | ] 38 | return members_dict 39 | -------------------------------------------------------------------------------- /multiagent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amanv1906/GENAI-CareerAssistant-Multiagent/9d37cbf7617275df6549bd3e2390900ebc496f81/multiagent.png -------------------------------------------------------------------------------- /prompts.py: -------------------------------------------------------------------------------- 1 | def get_supervisor_prompt_template(): 2 | system_prompt = """You are a supervisor tasked with managing a conversation between the" 3 | " following workers: {members}. Given the following user request," 4 | " respond with the worker to act next. Each worker will perform a" 5 | " task and respond with their results and status. When finished," 6 | " respond with FINISH." 7 | 8 | If the task is simple don't overcomplicate and run again and again 9 | just finish the task and provide the user with output. 10 | 11 | Like if the user asked to search on the web then just search and provide the information. 12 | If the user asked to analyze resume then just analyze it. 13 | If user ask to generate cover letter then just generate it. 14 | If user asks to search for jobs then just search for jobs. 15 | Don't be oversmart and route to wrong agent. 16 | 17 | """ 18 | return system_prompt 19 | 20 | 21 | def get_search_agent_prompt_template(): 22 | prompt = """ 23 | Your task is to search for job listings based on user-specified parameters. Always include the following fields in the output: 24 | - **Job Title:** Title of the job 25 | - **Company:** Company Name 26 | - **Location:** Location Name 27 | - **Job Description:** Job Description (if available) 28 | - **Apply URL:** URL to apply for the job (if available) 29 | 30 | Guidelines: 31 | pass the companies or industry params only if the user has provided the urn: ids. 32 | else include the company name or industry in the keyword search. 33 | 2. If searching for jobs at a specific company, include the company name in the keywords. 34 | 3. If the initial search does not return results, retry with alternative keywords up to three times. 35 | 4. Avoid redundant calls to the tool if job listing data is already retrieved. 36 | 37 | Output the results in markdown format as follows: 38 | 39 | Return in tabular format: 40 | | Job Title | Company | Location | Job Role (Summary) | Apply URL | PayRange | Job Posted (days ago)| 41 | 42 | If you successfully find job listings, return them in the format above. If not, proceed with the retry strategy. 43 | """ 44 | return prompt 45 | 46 | 47 | def get_analyzer_agent_prompt_template(): 48 | prompt = """ 49 | As a resume analyst, your role is to review a user-uploaded document and summarize the key skills, experience, and qualifications that are most relevant to job applications. 50 | 51 | ### Instructions: 52 | 1. Thoroughly analyze the uploaded resume. 53 | 2. Summarize the candidate's primary skills, professional experience, and qualifications. 54 | 3. Recommend the most suitable job role for the candidate, explaining the reasons for your recommendation. 55 | 56 | ### Desired Output: 57 | - **Skills, Experience, and Qualifications:** [Summarized content from the resume] 58 | 59 | """ 60 | return prompt 61 | 62 | 63 | def get_generator_agent_prompt_template(): 64 | generator_agent_prompt = """ 65 | You are a professional cover letter writer. Your task is to generate a cover letter in markdown format based on the user's resume and the provided job description (if available). 66 | 67 | Use the generate_letter_for_specific_job tool to create a tailored cover letter that highlights the candidate's strengths and aligns with the job requirements. 68 | ### Instructions: 69 | 1. Verify if both the resume and job description are provided. 70 | 2. If both are present, generate a cover letter using the provided details. 71 | 3. If the resume is missing, return: “To generate a cover letter, I need the resume content, which can be provided by the resume analyzer agent.” 72 | 73 | 74 | returns : 75 | Here is the cover letter: 76 | [Cover Letter Content] 77 | 78 | Download link for the cover letter: [Download link for the cover letter in clickable markdown format] 79 | """ 80 | return generator_agent_prompt 81 | 82 | 83 | def researcher_agent_prompt_template(): 84 | researcher_prompt = """ 85 | You are a web researcher agent tasked with finding detailed information on a specific topic. 86 | Use the provided tools to gather information and summarize the key points. 87 | 88 | Guidelines: 89 | 1. Only use the provided tool once with the same parameters; do not repeat the query. 90 | 2. If scraping a website for company information, ensure the data is relevant and concise. 91 | 92 | Once the necessary information is gathered, return the output without making additional tool calls. 93 | """ 94 | return researcher_prompt 95 | 96 | 97 | def get_finish_step_prompt(): 98 | return """ 99 | You have reached the end of the conversation. 100 | Confirm if all necessary tasks have been completed and if you are ready to conclude the workflow. 101 | If the user asks any follow-up questions, provide the appropriate response before finishing. 102 | """ 103 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pypdf 2 | langchain 3 | langgraph 4 | langchain-openai 5 | langchain-groq 6 | python-dotenv 7 | linkedin-api 8 | streamlit 9 | langsmith 10 | streamlit_chat 11 | streamlit_pills 12 | langchain_community 13 | firecrawl-py 14 | pymupdf 15 | streamlit-analytics2 16 | python-docx 17 | asgiref -------------------------------------------------------------------------------- /schemas.py: -------------------------------------------------------------------------------- 1 | from ast import List 2 | from typing import Literal, Optional, List, Union 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class RouteSchema(BaseModel): 7 | next_action: Literal[ 8 | "ResumeAnalyzer", 9 | "CoverLetterGenerator", 10 | "JobSearcher", 11 | "WebResearcher", 12 | "ChatBot", 13 | "Finish", 14 | ] = Field( 15 | ..., 16 | title="Next", 17 | description="Select the next role", 18 | ) 19 | 20 | 21 | class JobSearchInput(BaseModel): 22 | keywords: str = Field( 23 | description="Keywords describing the job role. (if the user is looking for a role in particular company then pass company with keywords)" 24 | ) 25 | location_name: Optional[str] = Field( 26 | description='Name of the location to search within. Example: "Kyiv City, Ukraine".' 27 | ) 28 | employment_type: Optional[ 29 | List[ 30 | Literal[ 31 | "full-time", 32 | "contract", 33 | "part-time", 34 | "temporary", 35 | "internship", 36 | "volunteer", 37 | "other", 38 | ] 39 | ] 40 | ] = Field(description="Specific type(s) of job to search for.") 41 | limit: Optional[int] = Field( 42 | default=5, description="Maximum number of jobs to retrieve." 43 | ) 44 | job_type: Optional[List[Literal["onsite", "remote", "hybrid"]]] = Field( 45 | description="Filter for remote jobs, onsite or hybrid" 46 | ) 47 | experience: Optional[ 48 | List[ 49 | Literal[ 50 | "internship", 51 | "entry-level", 52 | "associate", 53 | "mid-senior-level", 54 | "director", 55 | "executive", 56 | ] 57 | ] 58 | ] = Field( 59 | description='Filter by experience levels. Options are "internship", "entry level", "associate", "mid-senior level", "director", "executive". pass the exact arguments' 60 | ) 61 | listed_at: Optional[Union[int, str]] = Field( 62 | default=86400, 63 | description="Maximum number of seconds passed since job posting. 86400 will filter job postings posted in the last 24 hours.", 64 | ) 65 | distance: Optional[Union[int, str]] = Field( 66 | default=25, 67 | description="Maximum distance from location in miles. If not specified or 0, the default value of 25 miles is applied.", 68 | ) 69 | -------------------------------------------------------------------------------- /search.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import os 3 | import urllib 4 | import asyncio 5 | import requests 6 | from typing import List, Literal, Union, Optional 7 | from asgiref.sync import sync_to_async 8 | from linkedin_api import Linkedin 9 | from bs4 import BeautifulSoup 10 | 11 | employment_type_mapping = { 12 | "full-time": "F", 13 | "contract": "C", 14 | "part-time": "P", 15 | "temporary": "T", 16 | "internship": "I", 17 | "volunteer": "V", 18 | "other": "O", 19 | } 20 | 21 | experience_type_mapping = { 22 | "internship": "1", 23 | "entry-level": "2", 24 | "associate": "3", 25 | "mid-senior-level": "4", 26 | "director": "5", 27 | "executive": "6", 28 | } 29 | 30 | job_type_mapping = { 31 | "onsite": "1", 32 | "remote": "2", 33 | "hybrid": "3", 34 | } 35 | 36 | 37 | def build_linkedin_job_url( 38 | keywords, 39 | location=None, 40 | employment_type=None, 41 | experience_level=None, 42 | job_type=None, 43 | start=10, 44 | ): 45 | base_url = "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search/" 46 | 47 | # Prepare query parameters 48 | query_params = { 49 | "keywords": keywords, 50 | } 51 | 52 | if location: 53 | query_params["location"] = location 54 | 55 | if employment_type: 56 | if isinstance(employment_type, str): 57 | employment_type = [employment_type] 58 | employment_type = ",".join(employment_type) 59 | query_params["f_WT"] = employment_type 60 | 61 | if experience_level: 62 | if isinstance(experience_level, str): 63 | experience_level = [experience_level] 64 | experience_level = ",".join(experience_level) 65 | query_params["f_E"] = experience_level 66 | 67 | if job_type: 68 | if isinstance(job_type, str): 69 | job_type = [job_type] 70 | job_type = ",".join(job_type) 71 | query_params["f_WT"] = job_type 72 | 73 | # Build the complete URL 74 | query_string = urllib.parse.urlencode(query_params) 75 | full_url = f"{base_url}?{query_string}&sortBy=R" 76 | 77 | return full_url 78 | 79 | 80 | def validate_job_search_params(agent_input: Union[str, list], value_dict_mapping: dict): 81 | 82 | if isinstance(agent_input, list): 83 | for i, input_str in enumerate(agent_input.copy()): 84 | if not value_dict_mapping.get(input_str): 85 | agent_input.pop(i) 86 | elif isinstance(agent_input, str) and not value_dict_mapping.get(agent_input): 87 | agent_input = None 88 | else: 89 | agent_input = None 90 | 91 | return agent_input 92 | 93 | 94 | def get_job_ids_from_linkedin_api( 95 | keywords: str, 96 | location_name: str, 97 | employment_type=None, 98 | limit: Optional[int] = 5, 99 | job_type=None, 100 | experience=None, 101 | listed_at=86400, 102 | distance=None, 103 | ): 104 | try: 105 | job_type = validate_job_search_params(job_type, job_type_mapping) 106 | employment_type = validate_job_search_params( 107 | employment_type, employment_type_mapping 108 | ) 109 | experience_level = validate_job_search_params( 110 | experience, experience_type_mapping 111 | ) 112 | api = Linkedin(os.getenv("LINKEDIN_EMAIL"), os.getenv("LINKEDIN_PASS")) 113 | job_postings = api.search_jobs( 114 | keywords=keywords, 115 | job_type=employment_type, 116 | location_name=location_name, 117 | remote=job_type, 118 | limit=limit, 119 | experience=experience_level, 120 | listed_at=listed_at, 121 | distance=distance, 122 | ) 123 | # Extracting just the part after "jobPosting:" from the trackingUrn and the title using list comprehension 124 | job_ids = [job["trackingUrn"].split("jobPosting:")[1] for job in job_postings] 125 | return job_ids 126 | except Exception as e: 127 | print(f"Error in fetching job ids from LinkedIn API -> {e}") 128 | 129 | return [] 130 | 131 | 132 | def get_job_ids( 133 | keywords: str, 134 | location_name: str, 135 | employment_type: Optional[ 136 | List[ 137 | Literal[ 138 | "full-time", 139 | "contract", 140 | "part-time", 141 | "temporary", 142 | "internship", 143 | "volunteer", 144 | "other", 145 | ] 146 | ] 147 | ] = None, 148 | limit: Optional[int] = 10, 149 | job_type: Optional[List[Literal["onsite", "remote", "hybrid"]]] = None, 150 | experience: Optional[ 151 | List[ 152 | Literal[ 153 | "internship", 154 | "entry level", 155 | "associate", 156 | "mid-senior level", 157 | "director", 158 | "executive", 159 | ] 160 | ] 161 | ] = None, 162 | listed_at: Optional[Union[int, str]] = 86400, 163 | distance=None, 164 | ): 165 | if os.environ.get("LINKEDIN_SEARCH") == "linkedin_api": 166 | return get_job_ids_from_linkedin_api( 167 | keywords=keywords, 168 | location_name=location_name, 169 | employment_type=employment_type, 170 | limit=limit, 171 | job_type=job_type, 172 | experience=experience, 173 | listed_at=listed_at, 174 | distance=distance, 175 | ) 176 | 177 | try: 178 | # Construct the URL for LinkedIn job search 179 | job_url = build_linkedin_job_url( 180 | keywords=keywords, 181 | location=location_name, 182 | employment_type=employment_type, 183 | experience_level=experience, 184 | job_type=job_type, 185 | ) 186 | 187 | # Send a GET request to the URL and store the response 188 | response = requests.get( 189 | job_url, timeout=30, headers={"User-Agent": "Mozilla/5.0"} 190 | ) 191 | 192 | # Get the HTML, parse the response and find all list items(jobs postings) 193 | list_data = response.text 194 | list_soup = BeautifulSoup(list_data, "html.parser") 195 | page_jobs = list_soup.find_all("li") 196 | 197 | # Create an empty list to store the job postings 198 | job_ids = [] 199 | # Itetrate through job postings to find job ids 200 | for job in page_jobs: 201 | base_card_div = job.find("div", {"class": "base-card"}) 202 | job_id = base_card_div.get("data-entity-urn").split(":")[3] 203 | job_ids.append(job_id) 204 | return job_ids 205 | except Exception as e: 206 | print(f"Error in fetching job ids from LinkedIn -> {e}") 207 | return [] 208 | 209 | 210 | async def fetch_job_details(session, job_id): 211 | # Construct the URL for each job using the job ID 212 | job_url = f"https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/{job_id}" 213 | 214 | # Send a GET request to the job URL 215 | async with session.get(job_url) as response: 216 | job_soup = BeautifulSoup(await response.text(), "html.parser") 217 | 218 | # Create a dictionary to store job details 219 | job_post = {} 220 | 221 | # Try to extract and store the job title 222 | try: 223 | job_post["job_title"] = job_soup.find( 224 | "h2", 225 | { 226 | "class": "top-card-layout__title font-sans text-lg papabear:text-xl font-bold leading-open text-color-text mb-0 topcard__title" 227 | }, 228 | ).text.strip() 229 | except Exception as exc: 230 | job_post["job_title"] = "" 231 | 232 | try: 233 | job_post["job_location"] = job_soup.find( 234 | "span", 235 | {"class": "topcard__flavor topcard__flavor--bullet"}, 236 | ).text.strip() 237 | except Exception as exc: 238 | job_post["job_location"] = "" 239 | 240 | # Try to extract and store the company name 241 | try: 242 | job_post["company_name"] = job_soup.find( 243 | "a", {"class": "topcard__org-name-link topcard__flavor--black-link"} 244 | ).text.strip() 245 | except Exception as exc: 246 | job_post["company_name"] = "" 247 | 248 | # Try to extract and store the time posted 249 | try: 250 | job_post["time_posted"] = job_soup.find( 251 | "span", {"class": "posted-time-ago__text topcard__flavor--metadata"} 252 | ).text.strip() 253 | except Exception as exc: 254 | job_post["time_posted"] = "" 255 | 256 | # Try to extract and store the number of applicants 257 | try: 258 | job_post["num_applicants"] = job_soup.find( 259 | "span", 260 | { 261 | "class": "num-applicants__caption topcard__flavor--metadata topcard__flavor--bullet" 262 | }, 263 | ).text.strip() 264 | except Exception as exc: 265 | job_post["num_applicants"] = "" 266 | 267 | # Try to extract and store the job description 268 | try: 269 | job_description = job_soup.find( 270 | "div", {"class": "decorated-job-posting__details"} 271 | ).text.strip() 272 | job_post["job_desc_text"] = job_description 273 | except Exception as exc: 274 | job_post["job_desc_text"] = "" 275 | 276 | try: 277 | # Try to extract and store the apply link 278 | apply_link_tag = job_soup.find("a", class_="topcard__link") 279 | if apply_link_tag: 280 | apply_link = apply_link_tag.get("href") 281 | job_post["apply_link"] = apply_link 282 | except Exception as exc: 283 | job_post["apply_link"] = "" 284 | 285 | return job_post 286 | 287 | 288 | async def get_job_details_from_linkedin_api(job_id): 289 | try: 290 | api = Linkedin(os.getenv("LINKEDIN_EMAIL"), os.getenv("LINKEDIN_PASS")) 291 | job_data = await sync_to_async(api.get_job)( 292 | job_id 293 | ) # Assuming this function is async and fetches job data 294 | 295 | # Construct the job data dictionary with defaults 296 | job_data_dict = { 297 | "company_name": job_data.get("companyDetails", {}) 298 | .get( 299 | "com.linkedin.voyager.deco.jobs.web.shared.WebCompactJobPostingCompany", 300 | {}, 301 | ) 302 | .get("companyResolutionResult", {}) 303 | .get("name", ""), 304 | "company_url": job_data.get("companyDetails", {}) 305 | .get( 306 | "com.linkedin.voyager.deco.jobs.web.shared.WebCompactJobPostingCompany", 307 | {}, 308 | ) 309 | .get("companyResolutionResult", {}) 310 | .get("url", ""), 311 | "job_desc_text": job_data.get("description", {}).get("text", ""), 312 | "work_remote_allowed": job_data.get("workRemoteAllowed", ""), 313 | "job_title": job_data.get("title", ""), 314 | "company_apply_url": job_data.get("applyMethod", {}) 315 | .get("com.linkedin.voyager.jobs.OffsiteApply", {}) 316 | .get("companyApplyUrl", ""), 317 | "job_location": job_data.get("formattedLocation", ""), 318 | } 319 | except Exception as e: 320 | # Handle exceptions or errors in fetching or parsing the job data 321 | job_data_dict = { 322 | "company_name": "", 323 | "company_url": "", 324 | "job_desc_text": "", 325 | "work_remote_allowed": "", 326 | "job_title": "", 327 | "apply_link": "", 328 | "job_location": "", 329 | } 330 | 331 | return job_data_dict 332 | 333 | 334 | async def fetch_all_jobs(job_ids, batch_size=5): 335 | results = [] 336 | 337 | try: 338 | if os.environ.get("LINKEDIN_SEARCH") == "linkedin_api": 339 | return await asyncio.gather( 340 | *[get_job_details_from_linkedin_api(job_id) for job_id in job_ids] 341 | ) 342 | 343 | async with aiohttp.ClientSession() as session: 344 | tasks = [] 345 | for job_id in job_ids: 346 | task = asyncio.create_task(fetch_job_details(session, job_id)) 347 | tasks.append(task) 348 | 349 | # Await the completion of all tasks 350 | results = await asyncio.gather(*tasks) 351 | return results 352 | except Exception as exc: 353 | print(f"Error in fetching job details -> {exc}") 354 | 355 | return results 356 | -------------------------------------------------------------------------------- /tools.py: -------------------------------------------------------------------------------- 1 | # define tools 2 | import os 3 | import asyncio 4 | from dotenv import load_dotenv 5 | from langchain.pydantic_v1 import Field 6 | from langchain.tools import BaseTool, tool, StructuredTool 7 | from data_loader import load_resume, write_cover_letter_to_doc 8 | from schemas import JobSearchInput 9 | from search import get_job_ids, fetch_all_jobs 10 | from utils import FireCrawlClient, SerperClient 11 | 12 | load_dotenv() 13 | 14 | 15 | # Job search tools 16 | def linkedin_job_search( 17 | keywords: str, 18 | location_name: str = None, 19 | job_type: str = None, 20 | limit: int = 5, 21 | employment_type: str = None, 22 | listed_at=None, 23 | experience=None, 24 | distance=None, 25 | ) -> dict: # type: ignore 26 | """ 27 | Search LinkedIn for job postings based on specified criteria. Returns detailed job listings. 28 | """ 29 | job_ids = get_job_ids( 30 | keywords=keywords, 31 | location_name=location_name, 32 | employment_type=employment_type, 33 | limit=limit, 34 | job_type=job_type, 35 | listed_at=listed_at, 36 | experience=experience, 37 | distance=distance, 38 | ) 39 | job_desc = asyncio.run(fetch_all_jobs(job_ids)) 40 | return job_desc 41 | 42 | 43 | def get_job_search_tool(): 44 | """ 45 | Create a tool for the JobPipeline function. 46 | Returns: 47 | StructuredTool: A structured tool for the JobPipeline function. 48 | """ 49 | job_pipeline_tool = StructuredTool.from_function( 50 | func=linkedin_job_search, 51 | name="JobSearchTool", 52 | description="Search LinkedIn for job postings based on specified criteria. Returns detailed job listings", 53 | args_schema=JobSearchInput, 54 | ) 55 | return job_pipeline_tool 56 | 57 | 58 | # Resume Extraction Tool 59 | class ResumeExtractorTool(BaseTool): 60 | """ 61 | Extract the content of a resume from a PDF file. 62 | Returns: 63 | dict: The extracted content of the resume. 64 | """ 65 | name: str = "ResumeExtractor" 66 | description: str = "Extract the content of uploaded resume from a PDF file." 67 | 68 | def extract_resume(self) -> str: 69 | """ 70 | Extract resume content from a PDF file. 71 | Extract and structure job-relevant information from an uploaded CV. 72 | 73 | Returns: 74 | str: The content of the highlight skills, experience, and qualifications relevant to job applications, omitting personal information 75 | """ 76 | text = load_resume("temp/resume.pdf") 77 | return text 78 | 79 | def _run(self) -> dict: 80 | return self.extract_resume() 81 | 82 | 83 | # Cover Letter Generation Tool 84 | @tool 85 | def generate_letter_for_specific_job(resume_details: str, job_details: str) -> dict: 86 | """ 87 | Generate a tailored cover letter using the provided CV and job details. This function constructs the letter as plain text. 88 | returns: A dictionary containing the job and resume details for generating the cover letter. 89 | """ 90 | return {"job_details": job_details, "resume_details": resume_details} 91 | 92 | 93 | @tool 94 | def save_cover_letter_for_specific_job( 95 | cover_letter_content: str, company_name: str 96 | ) -> str: 97 | """ 98 | Returns a download link for the generated cover letter. 99 | Params: 100 | cover_letter_content: The combine information of resume and job details to tailor the cover letter. 101 | """ 102 | filename = f"temp/{company_name}_cover_letter.docx" 103 | file = write_cover_letter_to_doc(cover_letter_content, filename) 104 | abs_path = os.path.abspath(file) 105 | return f"Here is the download link: {abs_path}" 106 | 107 | 108 | # Web Search Tools 109 | @tool("google_search") 110 | def get_google_search_results( 111 | query: str = Field(..., description="Search query for web") 112 | ) -> str: 113 | """ 114 | search the web for the given query and return the search results. 115 | """ 116 | response = SerperClient().search(query) 117 | items = response.get("items") 118 | string = [] 119 | for result in items: 120 | try: 121 | string.append( 122 | "\n".join( 123 | [ 124 | f"Title: {result['title']}", 125 | f"Link: {result['link']}", 126 | f"Snippet: {result['snippet']}", 127 | "---", 128 | ] 129 | ) 130 | ) 131 | except KeyError: 132 | continue 133 | 134 | content = "\n".join(string) 135 | return content 136 | 137 | 138 | @tool("scrape_website") 139 | def scrape_website(url: str = Field(..., description="Url to be scraped")) -> str: 140 | """ 141 | Scrape the content of a website and return the text. 142 | """ 143 | try: 144 | content = FireCrawlClient().scrape(url) 145 | except Exception as exc: 146 | return f"Failed to scrape {url}" 147 | return content 148 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from langchain_community.utilities import GoogleSerperAPIWrapper 3 | from langchain_community.document_loaders import FireCrawlLoader 4 | 5 | from dotenv import load_dotenv 6 | 7 | load_dotenv() 8 | 9 | class SerperClient: 10 | """ 11 | A client for performing Google searches using the Serper API. 12 | 13 | This client provides synchronous and asynchronous methods for performing Google searches 14 | and retrieving the search results. 15 | 16 | Attributes: 17 | None 18 | 19 | Methods: 20 | search(query, num_results): Perform a Google search for the given query and return the search results. 21 | search_async(query, num_results): Asynchronously perform a Google search for the given query and return the search results. 22 | """ 23 | 24 | def __init__(self, serper_api_key: str = os.environ.get("SERPER_API_KEY")) -> None: 25 | self.serper_api_key = serper_api_key 26 | 27 | def search( 28 | self, 29 | query, 30 | num_results: int = 5, 31 | ): 32 | """ 33 | Perform a Google search for the given query and return the search results. 34 | 35 | Args: 36 | query (str): The search query. 37 | num_results (int, optional): The number of search results to retrieve. Defaults to GOOGLE_SEARCH_DEFAULT_RESULT_COUNT. 38 | 39 | Returns: 40 | dict: The search results as a dictionary. 41 | 42 | """ 43 | response = GoogleSerperAPIWrapper(k=num_results).results(query=query) 44 | # this is to make the response compatible with the response from the google search client 45 | items = response.pop("organic", []) 46 | response["items"] = items 47 | return response 48 | 49 | 50 | class FireCrawlClient: 51 | 52 | def __init__( 53 | self, firecrawl_api_key: str = os.environ.get("FIRECRAWL_API_KEY") 54 | ) -> None: 55 | self.firecrawl_api_key = firecrawl_api_key 56 | 57 | def scrape(self, url): 58 | docs = FireCrawlLoader( 59 | api_key=self.firecrawl_api_key, url=url, mode="scrape" 60 | ).lazy_load() 61 | 62 | page_content = "" 63 | for doc in docs: 64 | page_content += doc.page_content 65 | 66 | # limit to 10,000 characters 67 | return page_content[:10000] 68 | --------------------------------------------------------------------------------