Resume scanner: Leverage the power of LLM to improve your resume
Build a Streamlit application powered by Langchain, OpenAI and Google Generative AI
Introduction
This article shows how we can build a WEB application in Streamlit that scans and improves a resume using instruction-tuned Large Language Models (LLMs).
This is how the app should work: The first step is to upload a CV in PDF format. The app will then extract all the information, such as contact details, summary, work experience and skills. It will assess the quality of each section and return a score from 0 to 100. The app will also suggest improvements to make the text more appealing to recruiters.
We will leverage the power of LLMs, specifically Chat GPT from OpenAI and Gemini-pro from Google, to extract, assess, and enhance resumes.
We will use Langchain, prompt engineering, and retrieval augmented generation techniques to complete these steps.
Project structure
Let’s start by examining the structure of the resume scanner project.
You can find it in this GitHub repository.
It includes the following:
requirements.txt
: contains the required packages for installation.keys.env
: Your OpenAI, Gemini, and Cohere API keys are stored here.llm_functions.py
: reads LLM API keys from keys.env and instantiates the LLM in Langchain.retrieval.py
: the script used to create a Langchain retrieval, including document loaders, embeddings, vector stores, and retrievers.app_constants.py
: contains templates for creating LLM prompts.app_sidebar.py
: the sidebar is where you can choose the LLM model and its parameters (temperature and top_p), and enter your API keys.resume_analyzer.py
: this file contains the functions used to extract, assess, and improve each section of the resume using LLM. It is the core of the application.app_display_results.py
: the script used to display resume sections, assessments, scores, and improved texts.app.py
: It's the main script of the app. It calls all the scripts and is used to run the Streamlit application.
The following sections will guide you through each file.
Environment setup
To set up the environment, you can follow these steps:
First, clone the project from this GitHub repository using the following command:
gh repo clone AlaGrine/CV_Improver_with_LLMs
Then, create a virtual environment called virtualenv using the Python’s built-in venv
module:
python -m venv virtualenv
Next, activate the virtual environment as follows:
# On Windows, run:
.\virtualenv\scripts\activate
# On Unix or MacOS, run:
source virtualenv/bin/activate
Finally, install the required dependencies:
pip install -r requirements.txt
The environment is now ready.
API keys
To access the APIs for OpenAI, Gemini, and Cohere, you can get your API keys from their respective websites.
We will store the keys in a .env
file and use the dotenv library to read them and set them as environment variables.
from dotenv import load_dotenv, find_dotenv
try:
found_dotenv = find_dotenv('keys.env',usecwd=True)
load_dotenv(found_dotenv)
openai_api_key = os.getenv("api_key_openai")
google_api_key = os.getenv("api_key_google")
cohere_api_key = os.getenv("api_key_cohere")
if (openai_api_key=="Your_API_key") or (google_api_key=="Your_API_key") or (cohere_api_key=="Your_API_key"):
print("Please add your API keys to the keys.env file")
except Exception as e:
print(f"[ERROR] {e}")
The code is stored in llm_functions.py
The design of the application
In the sidebar, you can select the LLM provider, a model, and adjust its parameters. Additionally, you can choose the assistant language.
The API keys are automatically inserted in the text input widget by utilising the dotenv library to read the keys.env
file.
You can use the file uploader widget to upload your resume in PDF format. To analyse your resume, simply click the “Analyze resume” button on the main panel. The scanned resume will then be displayed section by section, along with an assessment and improved version of each section.
Here is a screenshot of the app.
The retrieval
Before analysing the resume, let’s create a Langchain retrieval, which includes a document loader to upload the resume, embeddings to create a numerical representation of the document, vector stores to store the embeddings, and a CohereRerank retriever to find the most relevant documents to pass to LLM.
Upload resume: Document Loader
You can upload your resume in PDF format.
Langchain’s PDFMinerLoader assists in loading and splitting resumes into an array of documents. Each document includes the page content and source file metadata.
from langchain_community.document_loaders import PDFMinerLoader
import streamlit as st
def langchain_document_loader(file_path):
"""Load and split a PDF file in Langchain.
Parameters:
- file_path (str): path of the file.
Output:
- documents: list of Langchain Documents."""
if file_path.endswith(".pdf"):
loader = PDFMinerLoader(file_path=file_path)
else:
st.error("You can only upload .pdf files!")
# 1. Load and split documents
documents = loader.load_and_split()
# 2. Update the metadata: add document number
for i in range(len(documents)):
documents[i].metadata = {
"source": documents[i].metadata["source"],
"doc_number": i,
}
return documents
The document number is not included in the metadata.
For longer resumes, it is possible to have multiple documents. In such cases, the relevant information may be found in two consecutive documents.
To ensure that the subsequent document is added to the most relevant retrieved document, we have included the document number in the metadata.
Token Count
Token count is the main constraint for our application.
Although the input token limit is sufficient to process a standard resume (one to two pages) in one call, the output limit is smaller. GPT-3–5 and GPT-4 have an output token limit of 4096 tokens, while Gemini Pro has a limit of only 2048 tokens. For longer resumes, it is not possible to extract all information in one go. We will make multiple calls to LLM and retrieve information section by section (contact information, summary, work experience…).
import tiktoken
def tiktoken_tokens(documents,model="gpt-3.5-turbo-0125"):
"""Use tiktoken (tokeniser for OpenAI models) to return a list of token length per document."""
encoding = tiktoken.encoding_for_model(model) # returns the encoding used by the model.
tokens_length = [len(encoding.encode(doc)) for doc in documents]
return tokens_length
For shorter resumes, it is possible to extract several sections at once. However, our application is designed to analyse any CV, so we will extract information section by section to avoid exceeding the output token limit.
In the following sections, we will explore how to extract the most relevant documents using embeddings, vector stores, and retrievers.
Embeddings
Embeddings are numerical representations of text data in a high-dimensional vector space. For instance, the size of the embeddings vector size for OpenAI’s text-embedding-ada-002 model is 1536.
To identify the most similar documents to a query, we can search for vectors with the highest similarity to the query’s embeddings, using measures such as cosine similarity.
We will connect to the following API endpoints for embeddings:
def select_embeddings_model(LLM_service="OpenAI"):
"""Connect to the embeddings API endpoint."""
if LLM_service == "OpenAI":
embeddings = OpenAIEmbeddings(
model='text-embedding-ada-002',
api_key=openai_api_key)
if LLM_service == "Google":
embeddings = GoogleGenerativeAIEmbeddings(
model="models/embedding-001",
google_api_key=google_api_key
)
return embeddings
Vectorstores
A vectorstore is a database used to store embedding vectors. There are several open-source options for vector storage. We will use the Facebook AI Similarity Search (Faiss) vector database.
from langchain_community.vectorstores import FAISS
def create_vectorstore(embeddings, documents):
"""Create a Faiss vector database."""
vector_store = FAISS.from_documents(documents=documents, embedding=embeddings)
return vector_store
Vectorstore-backed retriever
The Vectorstore-backed retriever is a simple tool that employs semantic search to retrieve documents from a Vectorstore.
def Vectorstore_backed_retriever(vectorstore,search_type="similarity",k=4,score_threshold=None):
"""create a vectorsore-backed retriever.
Parameters:
search_type: Defines the type of search that the Retriever should perform.
Can be "similarity" (default), "mmr", or "similarity_score_threshold"
k: number of documents to return (Default: 4)
score_threshold: Minimum relevance threshold for similarity_score_threshold (default=None)
"""
search_kwargs={}
if k is not None:
search_kwargs['k'] = k
if score_threshold is not None:
search_kwargs['score_threshold'] = score_threshold
retriever = vectorstore.as_retriever(
search_type=search_type,
search_kwargs=search_kwargs
)
return retriever
However, it is important to note that the most similar documents may not always be the most relevant.
Cohere reranker
We will wrap our base retriever with a ContextualCompressionRetriever and use the Cohere rerank endpoint to reorder the results based on semantically relevance to the query.
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CohereRerank
from langchain_community.llms import Cohere
def CohereRerank_retriever(
base_retriever,
cohere_api_key,cohere_model="rerank-multilingual-v2.0", top_n=2
):
"""Build a ContextualCompressionRetriever using Cohere Rerank endpoint to reorder the results based on relevance.
Parameters:
base_retriever: a Vectorstore-backed retriever
cohere_api_key: the Cohere API key
cohere_model: The Cohere model can be either 'rerank-english-v2.0' or 'rerank-multilingual-v2.0', with the latter being the default.
top_n: top n results returned by Cohere rerank, default = 2.
"""
compressor = CohereRerank(
cohere_api_key=cohere_api_key,
model=cohere_model,
top_n=top_n
)
retriever_Cohere = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=base_retriever
)
return retriever_Cohere
Instantiate the LLMs
We will use the OpenAI and Google APIs and leverage the classes ChatOpenAI and ChatGoogleGenerativeAI from Langchain to instantiate the following models:
- OpenAI API: gpt-3.5-turbo-0125 and gpt-4-turbo-preview.
These models are paid. Updated pricing can be found on the OpenAI pricing page. - Google Gemini API: gemini-pro.
Free access to Gemini pro through Google AI Studio with up to 60 requests per minute.
In the sidebar, you can choose a model and adjust the following parameters:
- temperature: controls the degree of randomness in token selection. Higher values increase diversity, and hence, creativity.
- top_p: The cumulative probability cutoff for token selection. Higher values increase diversity.
The code used to instantiate the LLM and stored in llm_functions.py
is shown in the following snippet.
def instantiate_LLM(LLM_provider,api_key,temperature=0.5,top_p=0.95,model_name=None):
"""Instantiate LLM in Langchain.
Parameters:
LLM_provider (str): the LLM provider; in ["OpenAI","Google"]
model_name (str): in ["gpt-3.5-turbo", "gpt-3.5-turbo-0125", "gpt-4-turbo-preview", "gemini-pro"].
api_key (str): google_api_key or openai_api_key
temperature (float): Range: 0.0 - 1.0; default = 0.7
top_p (float): : Range: 0.0 - 1.0; default = 0.95.
"""
if LLM_provider == "OpenAI":
llm = ChatOpenAI(
api_key=api_key,
model=model_name,
temperature=temperature,
model_kwargs={
"top_p": top_p
}
)
if LLM_provider == "Google":
llm = ChatGoogleGenerativeAI(
google_api_key=api_key,
# model="gemini-pro",
model=model_name,
temperature=temperature,
top_p=top_p,
convert_system_message_to_human=True
)
return llm
Resume scanner
Now that the Langchain retrieval has been created and the LLMs instantiated, we can begin scanning the resume.
In the following sections, we will invoke the LLM to:
- Extract information from the resume (such as contact information, skills, work experience…).
- Evaluate the quality of each section and return a score on a scale from 0 to 100.
- Improve the text and make it more appealing to recruiters.
Prompt Templates
The app_constants.py
file stores the complete list of prompt templates that guide the LLM in extracting, evaluating, and enhancing the content of each section of the CV. You can access it here.
Here are a few examples.
The template used to extract and evaluate contact information is as follows:
templates[
"Contact__information"
] = """Extract and evaluate the contact information. \
Output a dictionary with the following keys:
- candidate__name
- candidate__title
- candidate__location
- candidate__email
- candidate__phone
- candidate__social_media: Extract a list of all social media profiles, blogs or websites.
- evaluation__ContactInfo: Evaluate in {language} the contact information.
- score__ContactInfo: Rate the contact information by giving a score (integer) from 0 to 100.
"""
This is the template for assessing and improving the resume summary.
PROMPT_IMPROVE_SUMMARY = """Your are given a resume (delimited by <resume></resume>) \
and a summary (delimited by <summary></summary>).
1. In {language}, evaluate the summary (format and content) .
2. Rate the summary by giving an integer score from 0 to 100. \
If the summary is "unknown", the score is 0.
3. In {language}, strengthen the summary. The summary should not exceed 5 sentences. \
If the summary is "unknown", generate a strong summary in {language} with no more than 5 sentences. \
Please include: years of experience, top skills and experiences, some of the biggest achievements, and finally an attractive objective.
4. Format your response as a dictionary with the following keys: evaluation__summary, score__summary, CV__summary_enhanced.
<summary>
{summary}
</summary>
------
<resume>
{resume}
</resume>
"""
As you can see, the prompt template has a placeholder for the context, which can be either full documents or relevant retrieved documents, as well as the assistant language.
We have provided clear and specific instructions to the model to guide its responses. To obtain relevant results, we have also:
- Used delimiters (such as triple backticks and XML tags) for clear indication of context.
- Formatted the output as a JSON structured object. Specifically, the response content is a string structured as dictionary. We can use
json.loads
to load the response content into a JSON dictionary. If it fails, parsing the formatted output will be straightforward. - Specified the steps for completing a task. For example, to analyse the summary section, we asked the model to first evaluate its content and its format, then rate its quality by giving an integer score from 0 to 100, then strengthen the summary, and finally output a Json dictionary.
It is evident that LLM can perform multiple tasks in a single call. For instance, our inference tasks involve extracting information such as skills and academic achievements, evaluating the text’s quality, and rating it on a scale of 0 to 100. LLMs are powerful as they can provide relevant answers for multiple tasks with just one direct prompt, without any examples (i.e. zero-shot), unlike traditional machine learning workflows that require a separate model for each task.
Invoke LLM
Now that we have created the prompt, we will pass it to the LLM to get a response back.
def invoke_LLM(
llm,
documents,
resume_sections: list,
info_message="",
language=ASSISTAN_LANGUAGE,
):
"""Invoke LLM and get a response.
Parameters:
- llm: the LLM to invoke
- documents: the Langchain Documents.
- resume_sections (list): List of resume sections to be parsed.
- info_message (str): display an informational message.
- language (str): the assistant language.
Output:
- response_content (str): the content of the LLM response.
- response_tokens_count (int): count of response tokens.
"""
# 1. Display the info message
st.info(f"**{get_current_time()}** \t{info_message}")
# 2. Create the promptTemplate
prompt_template = create_prompt_template(
resume_sections,
language=language,
)
# 3. Format the PromptTemplate
if language is not None:
prompt = prompt_template.format_prompt(text=documents, language=language).text
else:
prompt = prompt_template.format_prompt(text=documents).text
# 4. Invoke the LLM
response = llm.invoke(prompt)
response_content = response.content[
response.content.find("{") : response.content.rfind("}") + 1
]
response_tokens_count = sum(tiktoken_tokens([response_content]))
print(f"""response tokens: {response_tokens_count}""")
return response_content, response_tokens_count
Let’s start by extracting the contact details.
try:
response_content, response_tokens_count = invoke_LLM(
llm,
documents,
resume_sections=["Contact__information"],
info_message="Extract and evaluate contact information...",
language=ASSISTAN_LANGUAGE,
)
try:
# Load response_content into json dictionary
CONTACT_INFORMATION = json.loads(response_content, strict=False)
except Exception as e:
print("[ERROR] json.loads returns error:", e)
CONTACT_INFORMATION = {}
except Exception as error:
print("[ERROR]:", error)
CONTACT_INFORMATION = {}
The response content is a string structured as a JSON dictionary. If json.loads
fails, we will parse the content using the following function:
def ResponseContent_Parser(response_content,list_fields,list_rfind,list_exclude_first_car ):
"""This is a parsing function for any structured response content.
Parameters:
- response_content (str): the LLM response's content.
- list_fields (list): List of fields to parse.
A dictionary may be an element of the list. The key of the dictionary will not be parsed.
Example: [{'Contact__information':['candidate__name','candidate__title','candidate__location']},
'CV__summary']
The 'Contact__Information' field content will not be parsed in this example.
- list_rfind (list): To parse the content of a field, we first extract the text between this field and \
the next field. Then, extract the text using the Python `rfind` command, which returns the highest index in the text \
where the substring is found.
- list_exclude_first_car (list): Exclusion or not of the first and last characters (i.e. remove \")
Output:
- INFORMATION_dict: A dictionary, where fields are the keys and the extracted texts are the values.
"""
# 1. Format the list_fields as a list of tuples.
# Each tuple should contain the field, whether to extract information or not, and the key of the dictionary.
list_fields_formatted = []
for field in list_fields:
if type(field) is dict:
# The key of the dictionary will not be parsed.
list_fields_formatted.append((list(field.keys())[0],False,None))
for val in list(field.values())[0]:
list_fields_formatted.append((val,True,list(field.keys())[0]))
else:
list_fields_formatted.append((field,True,None))
list_fields_formatted.append((None,False,None))
# 2. Parse the response_content
Parsed_content = {}
for i in range(len(list_fields_formatted)-1):
if list_fields_formatted[i][1] is False:
Parsed_content[list_fields_formatted[i][0]] = {} # Initialize the dictionary
if list_fields_formatted[i][1]:
# 2.1. Extract the text between this field and the next one
extracted_value = extract_from_text(
response_content,
f"\"{list_fields_formatted[i][0]}\": ",
f"\"{list_fields_formatted[i+1][0]}\":"
)
# 2.2. Extract the text using the Python `rfind` command, which returns the highest index in the text where the substring is found.
if list_rfind[i] is not None:
extracted_value = extracted_value[:extracted_value.rfind(list_rfind[i])].strip()
# 2.3. Remove the first and last characters (i.e. remove \")
if list_exclude_first_car[i]:
extracted_value = extracted_value[1:-1].strip()
# 2.4. Update the dictionary Parsed_content.
if list_fields_formatted[i][2] is None:
Parsed_content[list_fields_formatted[i][0]] = extracted_value
else:
Parsed_content[list_fields_formatted[i][2]][list_fields_formatted[i][0]] = extracted_value
return Parsed_content
Here is how we can parse the contact details using this function:
list_fields = [{'Contact__information':['candidate__name','candidate__title','candidate__location',
'candidate__email','candidate__phone','candidate__social_media',
'evaluation__ContactInfo','score__ContactInfo']}]
list_rfind = [",\n",",\n",",\n",",\n",",\n",",\n",",\n",",\n","}\n"]
list_exclude_first_car = [True,True,True,True,True,True,False,True,False]
# Parse the content of the response
CONTACT_INFORMATION = ResponseContent_Parser(response_content,list_fields,list_rfind,list_exclude_first_car)
For all remaining sections of the resume, except for work experience and projects, we will use the same tactics to extract, evaluate and score the section. We will provide the LLM with a prompt containing the full documents, get the LLM’s response, and parse the content.
For work experience and projects sections, to ensure that the output token limit is not exceeded, particularly for gemini-pro, we will proceed in two steps:
1. Extract job and project titles, company names, and dates. Here the prompt given to LLM is augmented with the full documents.
2. Extract job responsibilities and project details for each job or project. Here, we will leverage our CohereRerank retriever to pass only the most relevant documents to the LLM.
Here is the code used to extract job responsibilities:
def Extract_Job_Responsibilities(llm, documents, retriever, PROFESSIONAL_EXPERIENCE):
"""Extract job responsibilities for each job in PROFESSIONAL_EXPERIENCE."""
st.info(f"**{get_current_time()}** \tExtract work experience responsibilities...")
for i in range(len(PROFESSIONAL_EXPERIENCE["Work__experience"])):
try:
Work_experience_i = PROFESSIONAL_EXPERIENCE["Work__experience"][i]
print(f"\n\n{i}: {Work_experience_i['job__title']}", end=" | ")
# 1. Query
query = f"""Extract from the resume delimited by triple backticks \
all the duties and responsabilities of the following work experience: \
(title = '{Work_experience_i['job__title']}'"""
if str(Work_experience_i["job__company"]) != "unknown":
query += f" and company = '{Work_experience_i['job__company']}'"
if str(Work_experience_i["job__start_date"]) != "unknown":
query += f" and start date = '{Work_experience_i['job__start_date']}'"
if str(Work_experience_i["job__end_date"]) != "unknown":
query += f" and end date = '{Work_experience_i['job__end_date']}'"
query += ")\n"
# 2. For longer CVs (i.e. number of documents > 2),
# use the CohereRerank retriever to find the most relevant documents.
if len(documents)>2:
try:
relevant_documents = get_relevant_documents(query, documents, retriever)
except Exception as error:
print(f"[ERROR] get_relevant_documents error: {error}")
relevant_documents = documents
else:
relevant_documents = documents
print(f"relevant docs : {len(relevant_documents)}", end=" | ")
# 3. Invoke the LLM
prompt = (
query
+ f"""Output the duties in a json dictionary with the following keys (__duty_id__,__duty__). \
Use this format: "1":"duty","2":"other duty".
Resume:\n\n ```{relevant_documents}```"""
)
print(f"prompt tokens: {sum(tiktoken_tokens([prompt]))}", end=" | ")
response = llm.invoke(prompt)
response_content = response.content[response.content.find("{") : response.content.rfind("}") + 1]
print(f"""response tokens: {sum(tiktoken_tokens([response_content]))}""")
try:
# 4. Convert the response content to json dict and update work_experience
Work_experience_i["work__duties"] = json.loads(response_content, strict=False)
except Exception as e:
print("\n[ERROR] json.loads returns error:", e, "\n")
print("\n['INFO'] Parse response content...\n")
Work_experience_i["work__duties"] = {}
list_duties = (
response_content[response_content.find("{") + 1 : response_content.rfind("}")].strip().split(",\n")
)
for j in range(len(list_duties)):
try:
Work_experience_i["work__duties"][f"{j+1}"] = (list_duties[j].split('":')[1].strip()[1:-1])
except:
Work_experience_i["work__duties"][f"{j+1}"] = "unknown"
except Exception as exception:
print(f"[ERROR] {exception}")
Work_experience_i["work__duties"] = {}
return PROFESSIONAL_EXPERIENCE
Results
Now that each section of the CV has been extracted, assessed, and enhanced, it is time to present the results.
We have created a function to customise st.markdown
by specifying custom background color, text color, font size, and text alignment.
def custom_markdown(
text,
html_tag="p",
bg_color="white",
color="black",
font_size=None,
text_align="left",
):
"""Customise markdown by specifying custom background colour, text colour, font size, and text alignment."""
style = f'style="background-color:{bg_color};color:{color};font-size:{font_size}px; \
text-align: {text_align};padding: 25px 25px 25px 25px;border-radius:2%;"'
body = f"<{html_tag} {style}> {text}</{html_tag}>"
st.markdown(body, unsafe_allow_html=True)
st.write("")
First, the resume’s overview, top 3 strengths, and top 3 weaknesses are displayed.
The scores are then displayed to give a general indication of the resume’s quality. The resume is evaluated based on eight sections, each scored out of 100: contact information, summary, work experience, skills, education, language, projects, and certifications.
The following code shows how the scores are displayed in side-by-side columns:
def display_scores_in_columns(section_names: list, scores: list, column_width: list):
"""Display the scores of the sections in side-by-side columns.
The column_width variable sets the width of the columns."""
columns = st.columns(column_width)
for i, column in enumerate(columns):
with column:
custom_markdown(
text=f"<b>{section_names[i]} <br><br> {scores[i]}</b>",
bg_color=set_background_color(scores[i]),
text_align="center",
)
Finally, the analysis of each section is presented in a st.expander
. This is a container that can be expanded or collapsed.
Hers is the code to view the section results:
def display_section_results(
expander_label: str, #A string to use as the header for the expander
expander_header_fields: list, # Example: company name and dates of the work experience
expander_header_links: list, # social media, blogs and web sites.
score: int, # the score of the section
section_original_text_header: str,
section_original_text: list,
original_text_bullet_points: bool,
section_assessment,
section_improved_text,
):
"""View the section results in the Streamlit application."""
# Expander label
if score > -1:
expander_label += f"- 🎯 **{score}**/100"
with st.expander(expander_label):
st.write("")
# 1. Display the header fields (for example, the company and dates of the work experience)
if expander_header_fields is not None:
for field in expander_header_fields:
if not isinstance(field, list):
st.markdown(field)
else:
# display fields in side-by-side columns.
columns = st.columns(len(field))
for i, column in enumerate(columns):
with column:
st.markdown(field[i])
# 2. View the links (social media, blogs and web sites)
if expander_header_links is not None:
if not isinstance(expander_header_links, list):
link = expander_header_links.strip().replace('"', "")
if not link.startswith("http"):
link = "https://" + link
st.markdown(
f"""🌐 <a href="{link}" target="_blank">{link}""",
unsafe_allow_html=True,
)
else:
for link in expander_header_links:
if not link.startswith("http"):
link = "https://" + link
st.markdown(
f"""🌐 <a href="{link}" target="_blank">{link}""",
unsafe_allow_html=True,
)
# 3. View the original text
if section_original_text_header is not None:
st.write("")
st.markdown(section_original_text_header)
if section_original_text is not None:
for text in section_original_text:
if original_text_bullet_points:
st.markdown(f"- {text}")
else:
st.markdown(text)
# 4. Display of section score
st.divider()
custom_markdown(
html_tag="h4",
text=f"<b>🎯 Score: {score}</b>/<small>100</small>",
)
# 5. Display the assessmnet
bg_color = set_background_color(score)
assessment = markdown_to_html(format_object_to_string(section_assessment))
custom_markdown(
text=f"<b>🔎 Assessment:</b> <br><br> {assessment}",
html_tag="div",
bg_color=bg_color,
)
# 6. View the improved text
if section_improved_text is not None:
improved_text = markdown_to_html(
format_object_to_string(section_improved_text)
)
custom_markdown(
text=f"<b>🚀 Improvement:</b> <br><br> {improved_text}",
html_tag="div",
bg_color="#ededed",
)
st.write("")
Let’s explain the code:
- The expander label consists of the section name and a score out of 100.
- The header fields are the fields to display at the top of the expander. For instance, in a work experience container, the header should include the company name and dates of employment.
- The header links convert text to HTML links, which are displayed using
st.markdown
with theunsafe_allow_html
parameter set to True. This is useful for displaying your social media profiles, blogs, and websites. - The original content is then displayed, followed by the score, the section evaluation, and the improved version, utilising the
custom_markdown
function.
For instance, here is how the work experience is displayed.
for work_experience in SCANNED_RESUME["Work__experience"]:
display_section_results(
expander_label=f"{work_experience['job__title']}",
expander_header_fields=[
[
f"**Company:**\n {work_experience['job__company']}",
f"**📅**\n {work_experience['job__start_date']} - {work_experience['job__end_date']}",
]
],
expander_header_links=None,
score=work_experience["Score__WorkExperience"],
section_original_text_header="**📋 Responsibilities:**",
section_original_text=list(
work_experience["work__duties"].values()
),
original_text_bullet_points=True,
section_assessment=work_experience["Comments__WorkExperience"],
section_improved_text=work_experience[
"Improvement__WorkExperience"
],
)
Please note that the numbers in the improved text are fictitious and generated by AI. Please only report your actual achievements, as no application can accurately determine what you have accomplished.
Put it all together
Let’s put it all together in app.py
, the main script of the Streamlit application. It calls all the scripts we've created so far.
import streamlit as st
from app_sidebar import sidebar
from llm_functions import instantiate_LLM_main, get_api_keys_from_local_env
from retrieval import retrieval_main
from resume_analyzer import resume_analyzer_main
from app_display_results import display_resume_analysis
def main():
"""Analyse and enhance the uploaded resume."""
if st.button("Analyze resume"):
with st.spinner("Please wait..."):
try:
# 1. Create the Langchain retrieval
retrieval_main()
# 2. Instantiate a deterministic LLM with a temperature of 0.0.
st.session_state.llm = instantiate_LLM_main(temperature=0.0, top_p=0.95)
# 3. Instantiate LLM with temperature >0.1 for creativity.
st.session_state.llm_creative = instantiate_LLM_main(
temperature=st.session_state.temperature,
top_p=st.session_state.top_p,
)
# 4. Analyze the resume
st.session_state.SCANNED_RESUME = resume_analyzer_main(
llm=st.session_state.llm,
llm_creative=st.session_state.llm_creative,
documents=st.session_state.documents,
)
# 5. Display results
display_resume_analysis(st.session_state.SCANNED_RESUME)
except Exception as e:
st.error(f"An error occured: {e}")
if __name__ == "__main__":
# 1. Set app configuration
st.set_page_config(page_title="Resume Scanner", page_icon="🚀")
st.title("🔎 Resume Scanner")
# 2. Get API keys from local "keys.env" file
openai_api_key, google_api_key, cohere_api_key = get_api_keys_from_local_env()
# 3. Create the sidebar
sidebar(openai_api_key, google_api_key, cohere_api_key)
# 4. File uploader widget
st.session_state.uploaded_file = st.file_uploader(
label="**Upload Resume**",
accept_multiple_files=False,
type=(["pdf"]),
)
# 5. Analyze the uploaded resume
main()
To execute the application, run the following command in the command line:
streamlit run app.py
Conclusion
In this article, we walked you through the steps of creating a CV improver application in Streamlit powered by OpenAI and Google AI APIs.
We used Langchain to create a retrieval system that includes a document loader and a CohereRerank retriever. We also utilized its built-in functions to create prompt templates and interact with the LLM.
We demonstrated how to guide the LLM responses by providing clear and specific instructions, outlining the steps to complete a task, and formatting the output as a JSON structured object.
We also explained how to display the results in the Streamlit application.
Finally, it is important to note that although LLMs are powerful, no application can accurately determine your accomplishments. AI applications can provide recommendations, but you should only report your actual achievements.
You can find the code used in this article in this GitHub Repo: