AI Resume Analyzer & Cover Letter Gen
Build an AI app that scores your resume and generates a cover letter.
Introduction
30 Second Summary
Applying for jobs means rewriting your resume and cover letter for every single posting. It takes hours just to customize your materials for one role.
In this project, you will build an AI-powered web app that analyzes your resume against any job description, scores your skill compatibility, and generates a tailored cover letter and enhanced resume. Both documents are downloadable as PDF and DOCX files.
What You'll Build
A local Python web application built with Streamlit that uses the Google Gemini API to analyze resumes, score compatibility, and generate personalized application documents.
By the end of this project, you'll have:
- A working Streamlit web app with file upload and text input for resumes and job descriptions.
- An AI compatibility scorer that identifies matching and missing skills with a percentage match.
- A cover letter generator and enhanced resume generator with downloadable PDF and DOCX files.
- Secret Mission: An interview prep feature that generates likely questions and suggested talking points based on your skill gaps.
Are there any prerequisites?
You should have basic Python knowledge including variables, functions, and loops. No web development experience is needed because Streamlit handles the entire user interface in pure Python.
Set Up Your Project
Before you can build your AI-powered resume analyzer, you need a workspace with the right tools installed. Your app will depend on several Python libraries for the web interface, AI integration, PDF reading, and document generation.
In this step, you will create a dedicated project folder, install all the required packages, and connect to Google's free Gemini API so everything is ready for building.
In this step, get ready to:
- Create a project folder with a Python virtual environment and install all required packages.
- Get a free Gemini API key and set it as an environment variable.
- Verify the setup by running a test Streamlit app.
Create a project folder and install packages
A virtual environment keeps your project's packages separate from your system Python. This prevents version conflicts between different projects on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir resume-analyzer
cd resume-analyzer
Your terminal prompt should now show resume-analyzer as your current directory.
- Create and activate a virtual environment by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Not seeing (venv) in your prompt?
Make sure you typed the activate command exactly as shown for your operating system. On macOS it starts with source. On Windows it uses a backslash path.
help me troubleshoot why my virtual environment is not activating
- Install all the packages your project needs by running:
pip install streamlit google-genai pdfplumber python-docx fpdf2
What are these packages?
- streamlit builds the interactive web interface in pure Python.
- google-genai connects your app to Google's Gemini AI model.
- pdfplumber extracts text from uploaded PDF resumes.
- python-docx generates downloadable Word documents.
- fpdf2 generates downloadable PDF documents.
Wait for the installation to finish. You should see a success message listing all installed packages.
Seeing an installation error?
Make sure (venv) appears in your terminal prompt before running pip install. If not, re-run the activation command for your operating system.
help me fix a pip install error in my virtual environment
Get a free Gemini API key
Your app uses Google's Gemini API to analyze resumes and generate cover letters. The API is free to use on the free tier, which includes free input and output tokens.
- Open your web browser and navigate to Google AI Studio's API key page.
- Sign in with your Google account if prompted.
- Click Create API key to generate a new key.
- Copy the API key that appears. You will only see the full key once, so keep it somewhere safe.
What is an API key?
An API key is a unique string that identifies your app when it sends requests to the Gemini service. It lets Google track your usage and enforce rate limits.
Never share your API key publicly or commit it to version control.
- Back in your terminal (where the virtual environment is active), set the API key as an environment variable by running:
macOS
export GEMINI_API_KEY="your-api-key-here"
Replace your-api-key-here with the key you just copied. Keep the quotes.
Windows
set GEMINI_API_KEY=your-api-key-here
Replace your-api-key-here with the key you just copied. Do not add quotes on Windows.
This variable only lasts for this session
The environment variable you just set only exists in this terminal window. If you close and reopen the terminal, you will need to activate the virtual environment and set the variable again.
Verify the setup with a test app
Now you will create a small test script to confirm that Streamlit runs and the Gemini client initializes without errors.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the resume-analyzer folder, and click Open.
- Create a new file by clicking File then New File.
- Save it immediately as app.py using Cmd+S (macOS) or Ctrl+S (Windows).
- Paste the following test code into app.py:
import streamlit as st
import os
from google import genai
# Create the Gemini client using the API key from the environment variable
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
# Display a success message if everything loaded without errors
st.write("Gemini API connected successfully!")
What does this code do?
- from google import genai loads the Gemini SDK so you can interact with the AI model.
- genai.Client(api_key=...) creates a client that authenticates with your API key.
- os.environ.get("GEMINI_API_KEY") reads the key from the environment variable you set earlier.
- st.write(...) displays a message on the Streamlit web page. If you see this message, everything is working.
- Save app.py with Cmd+S (macOS) or Ctrl+S (Windows).
- Switch back to the terminal where your virtual environment is active and the GEMINI_API_KEY is set.
- Run the test app:
streamlit run app.py
Your browser should automatically open a new tab at http://localhost:8501 showing the message "Gemini API connected successfully!" on the page.
Not seeing the success message?
If the browser did not open automatically, manually navigate to http://localhost:8501 in your browser.
If you see an error about a missing module, make sure (venv) appears in your terminal prompt before running the command. If not, re-activate the virtual environment.
If you see an error about the API key, confirm you ran the export (macOS) or set (Windows) command in the same terminal session.
help me troubleshoot my Streamlit test app not showing the success message
- Once you see the success message, press Ctrl+C in your terminal to stop the Streamlit server.
✔️ Awesome, I've got everything!
Great. Make sure you have saved app.py in your resume-analyzer folder.
ⓧ I'd like to double check the full code
Here is the complete app.py you should have at this point:
import streamlit as st
import os
from google import genai
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
st.write("Gemini API connected successfully!")
Your development environment is ready. Next up, you will build the resume upload interface and the AI-powered compatibility analyzer.
Build the Resume Upload and AI Compatibility Analyzer
Your environment is ready and the Gemini API is connected. Now it is time to build the core of the app: a way for users to upload their resume, paste a job description, and let AI tell them how well they match.
In this step, you will build the full upload-and-analyze flow. The app will extract text from a PDF resume using pdfplumber, send both the resume and job description to Gemini, and display a compatibility score with detailed skill lists.
In this step, get ready to:
- Build the Streamlit UI with a PDF file uploader and a job description text area.
- Extract text from uploaded PDF resumes using pdfplumber.
- Send resume and job description to the Gemini API and display a compatibility score with matching and missing skills.
Create the Streamlit app with file upload and job description input
Right now app.py is a small test script that confirms the Gemini connection. You will replace it with the real application shell: all the imports, the AI client, and a two-column layout where users upload a resume on the left and paste a job description on the right.
- In VS Code, open app.py and select all the existing code.
- Replace everything with the following imports and initialization:
import streamlit as st
import pdfplumber
import io
import os
import json
from google import genai
from docx import Document
from fpdf import FPDF
# Initialize the Gemini AI client using your API key
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
MODEL_ID = "gemini-2.5-flash"
What does this code do?
- The imports bring in everything you need: streamlit for the web UI, pdfplumber for reading PDFs, json for parsing AI responses, genai for the Gemini API, and Document / FPDF for generating downloadable files later.
- MODEL_ID stores the model name in one place so you can easily swap models without editing every function.
- Add the Streamlit page layout below the MODEL_ID line by pasting this code:
st.set_page_config(page_title="AI Resume Analyzer", page_icon="\ud83d\udcc4", layout="wide")
st.title("AI Resume Analyzer & Cover Letter Generator")
st.write("Upload your resume and paste a job description to get a compatibility score, tailored cover letter, and enhanced resume.")
# Create a two-column layout for inputs
col1, col2 = st.columns(2)
with col1:
st.subheader("Your Resume")
uploaded_file = st.file_uploader("Upload your resume (PDF)", type=["pdf"])
with col2:
st.subheader("Job Description")
job_description = st.text_area(
"Paste the job description here",
height=300,
placeholder="Paste the full job description..."
)
What does this code do?
- st.set_page_config sets the browser tab title and switches to a wide layout for more screen space.
- st.columns(2) splits the page into two equal columns. The left column holds a PDF uploader and the right column holds a text area for pasting a job description.
- type=["pdf"] restricts the uploader to PDF files only.
- Save app.py.
- Run the app in your terminal by running this command:
streamlit run app.py
- Your browser opens automatically. You should see a page titled "AI Resume Analyzer & Cover Letter Generator" with a PDF upload widget on the left and a large text area on the right.
Seeing an error in the terminal?
- If you see ModuleNotFoundError, make sure your virtual environment is activated before running the command.
- If the browser does not open, look for a URL in the terminal output (usually http://localhost:8501) and open it manually.
- If you see a syntax error, double-check that you replaced ALL the old test code and that the indentation inside with col1: and with col2: uses four spaces.
Why is my Streamlit app not loading in the browser?
Extract text from uploaded PDFs
The uploaded file is just raw binary data. You need a function that reads through every page of the PDF and pulls out the text content so the AI can analyze it.
- In app.py, add the following function between the MODEL_ID line and the st.set_page_config line:
def extract_text_from_pdf(pdf_file):
# Loop through every page and collect the text
text = ""
with pdfplumber.open(pdf_file) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
return text
What does this code do?
- pdfplumber.open() accepts a file-like object (which is exactly what Streamlit's uploader gives you) and opens the PDF for reading.
- The for page in pdf.pages loop iterates through every page in the document. Some pages may have no extractable text (like images), so the if page_text check prevents adding empty content.
Now you need to wire this function into the upload column so it runs whenever someone uploads a file.
- Find the with col1: block in your code. It currently looks like this:
with col1:
st.subheader("Your Resume")
uploaded_file = st.file_uploader("Upload your resume (PDF)", type=["pdf"])
- Replace the entire with col1: block with this updated version that extracts and displays the text:
with col1:
st.subheader("Your Resume")
uploaded_file = st.file_uploader("Upload your resume (PDF)", type=["pdf"])
if uploaded_file:
# Extract text as soon as a file is uploaded
resume_text = extract_text_from_pdf(uploaded_file)
with st.expander("View extracted resume text"):
st.text(resume_text)
else:
resume_text = None
What does this code do?
- When a file is uploaded, the code immediately extracts the text and stores it in resume_text.
- st.expander creates a collapsible section so the raw text does not clutter the page. Learners can click to expand and verify the extraction worked.
- The else: resume_text = None ensures the variable exists even when no file is uploaded, preventing errors later.
- Save app.py. Your browser will automatically reload the app.
- Upload any PDF resume using the file uploader on the left. You should see a "View extracted resume text" expander appear below the upload widget. Click it to confirm your resume text displays.
Not seeing extracted text after uploading?
- Make sure the file you uploaded is actually a PDF (not a Word doc renamed to .pdf). The pdfplumber library can only read real PDF files.
- If the expander appears but shows nothing, your PDF might be image-based (a scan). Try a different resume PDF that was created digitally.
- Check that the extract_text_from_pdf function is placed ABOVE the st.set_page_config line. Python needs functions defined before they are called.
Why is my PDF showing empty text in the expander?
Build the AI compatibility analyzer
Extracting text is only half the job. Now you need to send that text along with a job description to the Gemini API and get back a structured analysis. The key is crafting a prompt that forces the AI to respond in valid JSON so you can parse and display the results cleanly.
- In app.py, add the following function directly below the extract_text_from_pdf function:
def analyze_compatibility(resume_text, job_description):
# Build a prompt that asks Gemini for structured JSON output
prompt = f"""Analyze the compatibility between this resume and job description.
Resume:
{resume_text}
Job Description:
{job_description}
Respond in valid JSON format with these exact keys:
{{
"compatibility_score": <number from 0 to 100>,
"matching_skills": [<list of skills found in both resume and job description>],
"missing_skills": [<list of skills required by job but not found in resume>],
"summary": "<brief 2-3 sentence summary of the match>"
}}
Only respond with the JSON, no other text."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
# Clean up markdown code fences if the model wraps its response
response_text = response.text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
return json.loads(response_text)
What does this prompt do?
The prompt uses prompt engineering techniques to get reliable structured output from the AI. It provides both the resume and job description as context, then demands a specific JSON shape with exact key names.
The cleanup code after the API call handles a common quirk: LLMs sometimes wrap JSON in markdown code fences (```json ... ```) even when told not to. Stripping those fences ensures json.loads() can parse the response.
Next, you need the UI logic that triggers this analysis when the user clicks a button.
- Add the following code at the very bottom of app.py, after the with col2: block:
if resume_text and job_description:
if st.button("Analyze Compatibility", type="primary"):
with st.spinner("Analyzing your resume against the job description..."):
# Send both inputs to Gemini and store results
analysis = analyze_compatibility(resume_text, job_description)
st.session_state["analysis"] = analysis
st.session_state["resume_text"] = resume_text
st.session_state["job_description"] = job_description
Why use session_state?
Streamlit reruns the entire script from top to bottom every time the user interacts with the app. Without st.session_state, the analysis results would disappear the moment the user clicks anything else.
By storing results in session_state, the data persists across reruns. The button only appears when BOTH a resume and a job description are present, preventing incomplete submissions.
- Add the results display below the button logic, still at the bottom of app.py:
if "analysis" in st.session_state:
analysis = st.session_state["analysis"]
st.divider()
st.subheader("Compatibility Results")
# Display score and summary side by side
score_col, summary_col = st.columns([1, 2])
with score_col:
st.metric("Match Score", f"{analysis['compatibility_score']}%")
with summary_col:
st.write(analysis["summary"])
# Display matching and missing skills in two columns
skills_col1, skills_col2 = st.columns(2)
with skills_col1:
st.write("**Matching Skills**")
for skill in analysis["matching_skills"]:
st.write(f"- {skill}")
with skills_col2:
st.write("**Missing Skills**")
for skill in analysis["missing_skills"]:
st.write(f"- {skill}")
What does this code do?
- st.metric displays the compatibility percentage as a large, prominent number.
- The st.columns([1, 2]) creates an asymmetric layout: a narrow column for the score and a wider one for the summary text.
- The skills display loops through each list and renders them as bullet points in two equal columns, making it easy to compare what you have versus what you need.
- Save app.py. The browser reloads automatically.
- Upload a PDF resume, paste a job description in the right column, then click the blue **Analyze Compatibility** button.
- After a few seconds, you should see a "Compatibility Results" section with a percentage score, a summary paragraph, and two columns listing matching and missing skills.
Seeing a JSON decode error?
- This usually means the Gemini API returned text that is not valid JSON. Re-run the analysis by clicking the button again. LLMs occasionally produce slightly malformed output on the first try.
- If the error persists, check that your GEMINI_API_KEY environment variable is still set in the terminal where you started the app.
- Make sure you copied the full analyze_compatibility function including the code fence cleanup logic (the if response_text.startswith("```") block).
Why am I getting a JSON error from the Gemini API?
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and your results display correctly in the browser.
ⓧ I'd like to double check the full code
Here is the complete app.py at the end of this step:
import streamlit as st
import pdfplumber
import io
import os
import json
from google import genai
from docx import Document
from fpdf import FPDF
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
MODEL_ID = "gemini-2.5-flash"
def extract_text_from_pdf(pdf_file):
text = ""
with pdfplumber.open(pdf_file) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
return text
def analyze_compatibility(resume_text, job_description):
prompt = f"""Analyze the compatibility between this resume and job description.
Resume:
{resume_text}
Job Description:
{job_description}
Respond in valid JSON format with these exact keys:
{{
"compatibility_score": <number from 0 to 100>,
"matching_skills": [<list of skills found in both resume and job description>],
"missing_skills": [<list of skills required by job but not found in resume>],
"summary": "<brief 2-3 sentence summary of the match>"
}}
Only respond with the JSON, no other text."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
response_text = response.text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
return json.loads(response_text)
st.set_page_config(page_title="AI Resume Analyzer", page_icon="\ud83d\udcc4", layout="wide")
st.title("AI Resume Analyzer & Cover Letter Generator")
st.write("Upload your resume and paste a job description to get a compatibility score, tailored cover letter, and enhanced resume.")
col1, col2 = st.columns(2)
with col1:
st.subheader("Your Resume")
uploaded_file = st.file_uploader("Upload your resume (PDF)", type=["pdf"])
if uploaded_file:
resume_text = extract_text_from_pdf(uploaded_file)
with st.expander("View extracted resume text"):
st.text(resume_text)
else:
resume_text = None
with col2:
st.subheader("Job Description")
job_description = st.text_area(
"Paste the job description here",
height=300,
placeholder="Paste the full job description..."
)
if resume_text and job_description:
if st.button("Analyze Compatibility", type="primary"):
with st.spinner("Analyzing your resume against the job description..."):
analysis = analyze_compatibility(resume_text, job_description)
st.session_state["analysis"] = analysis
st.session_state["resume_text"] = resume_text
st.session_state["job_description"] = job_description
if "analysis" in st.session_state:
analysis = st.session_state["analysis"]
st.divider()
st.subheader("Compatibility Results")
score_col, summary_col = st.columns([1, 2])
with score_col:
st.metric("Match Score", f"{analysis['compatibility_score']}%")
with summary_col:
st.write(analysis["summary"])
skills_col1, skills_col2 = st.columns(2)
with skills_col1:
st.write("**Matching Skills**")
for skill in analysis["matching_skills"]:
st.write(f"- {skill}")
with skills_col2:
st.write("**Missing Skills**")
for skill in analysis["missing_skills"]:
st.write(f"- {skill}")
Your app can now read a resume, understand a job description, and tell you exactly where you stand. Next up, you will generate a tailored cover letter that highlights your matching skills and make it downloadable as a PDF and DOCX.
Build the Cover Letter Generator with Downloads
Your app can now score how well a resume matches a job description. That's useful information, but applying for jobs still requires writing a unique cover letter for every role.
In this step, you'll build the feature that turns analysis into action. The app will send the resume, job description, and matching skills to Gemini and ask it to write a professional cover letter. You'll also create helper functions that convert plain text into downloadable PDF and DOCX files.
In this step, get ready to:
- Write a cover letter generation function that prompts the Gemini API.
- Create PDF and DOCX document helper functions for downloadable output.
- Wire up the cover letter UI with download buttons in your Streamlit app.
Write the cover letter generation function
The cover letter function follows the same pattern as analyze_compatibility(). It sends the resume text, job description, and matching skills to Gemini with a structured prompt that requests a professional cover letter.
- In app.py, find the closing return json.loads(response_text) line of the analyze_compatibility() function.
- Add the following function below it, leaving one blank line between the functions:
def generate_cover_letter(resume_text, job_description, analysis):
prompt = f"""Write a professional cover letter for this job application.
Resume:
{resume_text}
Job Description:
{job_description}
Key matching skills: {', '.join(analysis['matching_skills'])}
Requirements:
- Professional tone, 3-4 paragraphs
- Highlight the matching skills naturally
- Show enthusiasm for the specific role
- Keep it under 400 words
- Do not include placeholder addresses or dates, start directly with the greeting
Write only the cover letter text, nothing else."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
return response.text.strip()
What does this code do?
This function builds a prompt that gives Gemini all the context it needs to write a relevant cover letter. It passes in the resume, job description, and the list of matching skills from the earlier analysis.
The requirements section of the prompt constrains the output to a professional format. Asking it to skip placeholder addresses and start with the greeting means the output is immediately usable.
- Save app.py.
You won't see a change in the browser yet. The next chunks will add the document helpers and UI that use this function.
Create the document download helper functions
Your app needs to convert plain text into downloadable files. You'll create two reusable helper functions: one for DOCX and one for PDF. Both accept a text string and a title, then return a file buffer that Streamlit's download button can serve.
- In app.py, add the following function below generate_cover_letter():
def create_docx(text, title):
# Create a new Word document with a heading
doc = Document()
doc.add_heading(title, level=1)
# Split text on double newlines to create separate paragraphs
for paragraph in text.split("\n\n"):
if paragraph.strip():
doc.add_paragraph(paragraph.strip())
# Save to an in-memory buffer so Streamlit can serve it
buffer = io.BytesIO()
doc.save(buffer)
buffer.seek(0)
return buffer
What does this code do?
The Document() class from python-docx creates a new Word file in memory. The function splits the AI-generated text on double newlines so each paragraph in the cover letter becomes a proper paragraph in the Word doc.
Using io.BytesIO() instead of saving to disk means the file never touches the filesystem. The buffer.seek(0) call rewinds to the start so Streamlit reads from the beginning when the user clicks download.
- Save app.py.
You won't see a change yet. The next function handles PDF generation.
- Add the PDF helper function below create_docx():
def create_pdf(text, title):
# Create a new PDF document
pdf = FPDF()
pdf.add_page()
# Add a centered title at the top
pdf.set_font("Helvetica", size=12)
pdf.cell(0, 10, title, new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(10)
# Write each paragraph as a multi-line cell
pdf.set_font("Helvetica", size=11)
for paragraph in text.split("\n\n"):
if paragraph.strip():
pdf.multi_cell(0, 6, paragraph.strip())
pdf.ln(4)
# Save to an in-memory buffer
buffer = io.BytesIO()
pdf.output(buffer)
buffer.seek(0)
return buffer
What does this code do?
The FPDF() class from fpdf2 creates a blank PDF in memory. The cell() method with align="C" renders the title centered at the top of the page.
The new_x="LMARGIN" and new_y="NEXT" parameters tell fpdf2 to move the cursor back to the left margin and down to the next line after printing the title. Then multi_cell() handles wrapping long paragraphs across the page width automatically.
- Save app.py.
Both helpers are now ready. The next substep wires them into the Streamlit interface.
Seeing a syntax error on save?
Make sure both functions are at the same indentation level as generate_cover_letter() above them. They should not be nested inside any other function or if block.
Also check that there are no extra or missing quotes around the f-string in generate_cover_letter(). The triple-quoted prompt must open and close with """.
help me fix a syntax error in my cover letter generator functions
Display the cover letter with download buttons
Now you'll connect the generation function and document helpers to the Streamlit UI. The cover letter section appears below the compatibility results, with a button to trigger generation and two download options.
- In app.py, scroll to the very bottom of the file.
- Find the last line inside the if "analysis" in st.session_state: block. It should be st.write(f"- {skill}") inside the missing skills column.
- Add the following code below that line, still inside the if "analysis" in st.session_state: block (indented by 4 spaces):
st.divider()
st.subheader("Generate Cover Letter")
if st.button("Generate Tailored Cover Letter"):
with st.spinner("Writing your cover letter..."):
cover_letter = generate_cover_letter(
st.session_state["resume_text"],
st.session_state["job_description"],
analysis
)
st.session_state["cover_letter"] = cover_letter
if "cover_letter" in st.session_state:
st.write(st.session_state["cover_letter"])
st.write("**Download your cover letter:**")
dl_col1, dl_col2 = st.columns(2)
with dl_col1:
docx_buffer = create_docx(st.session_state["cover_letter"], "Cover Letter")
st.download_button(
label="Download as DOCX",
data=docx_buffer,
file_name="cover_letter.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
with dl_col2:
pdf_buffer = create_pdf(st.session_state["cover_letter"], "Cover Letter")
st.download_button(
label="Download as PDF",
data=pdf_buffer,
file_name="cover_letter.pdf",
mime="application/pdf",
)
What does this code do?
The st.button("Generate Tailored Cover Letter") creates a clickable button. When pressed, it calls your generate_cover_letter() function and stores the result in st.session_state so it persists across re-renders.
The download section only appears once a cover letter exists. It creates two columns with st.columns(2) and places a DOCX download button in the left column and a PDF download button in the right. Each button calls the appropriate helper function to generate the file on-the-fly.
- Save app.py.
- Switch to your browser where the Streamlit app is running. If you already have analysis results displayed, you should now see a new Generate Cover Letter section with a Generate Tailored Cover Letter button below the skills lists.
- Click Generate Tailored Cover Letter. After a few seconds, a cover letter appears with two download buttons underneath.
Button not appearing?
Make sure the new code is indented at the same level as the st.divider() and st.subheader("Compatibility Results") lines above it. All of this code must be inside the if "analysis" in st.session_state: block.
If you see the button but nothing happens when you click it, check the terminal running Streamlit for any error messages. A common cause is a missing comma in the generate_cover_letter() call's arguments.
help me troubleshoot my cover letter generation button not working
✔️ Awesome, I've got everything!
Great. Double check you've saved app.py and that you can generate and download a cover letter.
ⓧ I'd like to double check the full code
import streamlit as st
import pdfplumber
import io
import os
import json
from google import genai
from docx import Document
from fpdf import FPDF
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
MODEL_ID = "gemini-2.5-flash"
def extract_text_from_pdf(pdf_file):
text = ""
with pdfplumber.open(pdf_file) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
return text
def analyze_compatibility(resume_text, job_description):
prompt = f"""Analyze the compatibility between this resume and job description.
Resume:
{resume_text}
Job Description:
{job_description}
Respond in valid JSON format with these exact keys:
{{
"compatibility_score": <number from 0 to 100>,
"matching_skills": [<list of skills found in both resume and job description>],
"missing_skills": [<list of skills required by job but not found in resume>],
"summary": "<brief 2-3 sentence summary of the match>"
}}
Only respond with the JSON, no other text."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
response_text = response.text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
return json.loads(response_text)
def generate_cover_letter(resume_text, job_description, analysis):
prompt = f"""Write a professional cover letter for this job application.
Resume:
{resume_text}
Job Description:
{job_description}
Key matching skills: {', '.join(analysis['matching_skills'])}
Requirements:
- Professional tone, 3-4 paragraphs
- Highlight the matching skills naturally
- Show enthusiasm for the specific role
- Keep it under 400 words
- Do not include placeholder addresses or dates, start directly with the greeting
Write only the cover letter text, nothing else."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
return response.text.strip()
def create_docx(text, title):
doc = Document()
doc.add_heading(title, level=1)
for paragraph in text.split("\n\n"):
if paragraph.strip():
doc.add_paragraph(paragraph.strip())
buffer = io.BytesIO()
doc.save(buffer)
buffer.seek(0)
return buffer
def create_pdf(text, title):
pdf = FPDF()
pdf.add_page()
pdf.set_font("Helvetica", size=12)
pdf.cell(0, 10, title, new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(10)
pdf.set_font("Helvetica", size=11)
for paragraph in text.split("\n\n"):
if paragraph.strip():
pdf.multi_cell(0, 6, paragraph.strip())
pdf.ln(4)
buffer = io.BytesIO()
pdf.output(buffer)
buffer.seek(0)
return buffer
st.set_page_config(page_title="AI Resume Analyzer", page_icon="\U0001f4c4", layout="wide")
st.title("AI Resume Analyzer & Cover Letter Generator")
st.write("Upload your resume and paste a job description to get a compatibility score, tailored cover letter, and enhanced resume.")
col1, col2 = st.columns(2)
with col1:
st.subheader("Your Resume")
uploaded_file = st.file_uploader("Upload your resume (PDF)", type=["pdf"])
if uploaded_file:
resume_text = extract_text_from_pdf(uploaded_file)
with st.expander("View extracted resume text"):
st.text(resume_text)
else:
resume_text = None
with col2:
st.subheader("Job Description")
job_description = st.text_area(
"Paste the job description here",
height=300,
placeholder="Paste the full job description..."
)
if resume_text and job_description:
if st.button("Analyze Compatibility", type="primary"):
with st.spinner("Analyzing your resume against the job description..."):
analysis = analyze_compatibility(resume_text, job_description)
st.session_state["analysis"] = analysis
st.session_state["resume_text"] = resume_text
st.session_state["job_description"] = job_description
if "analysis" in st.session_state:
analysis = st.session_state["analysis"]
st.divider()
st.subheader("Compatibility Results")
score_col, summary_col = st.columns([1, 2])
with score_col:
st.metric("Match Score", f"{analysis['compatibility_score']}%")
with summary_col:
st.write(analysis["summary"])
skills_col1, skills_col2 = st.columns(2)
with skills_col1:
st.write("**Matching Skills**")
for skill in analysis["matching_skills"]:
st.write(f"- {skill}")
with skills_col2:
st.write("**Missing Skills**")
for skill in analysis["missing_skills"]:
st.write(f"- {skill}")
st.divider()
st.subheader("Generate Cover Letter")
if st.button("Generate Tailored Cover Letter"):
with st.spinner("Writing your cover letter..."):
cover_letter = generate_cover_letter(
st.session_state["resume_text"],
st.session_state["job_description"],
analysis
)
st.session_state["cover_letter"] = cover_letter
if "cover_letter" in st.session_state:
st.write(st.session_state["cover_letter"])
st.write("**Download your cover letter:**")
dl_col1, dl_col2 = st.columns(2)
with dl_col1:
docx_buffer = create_docx(st.session_state["cover_letter"], "Cover Letter")
st.download_button(
label="Download as DOCX",
data=docx_buffer,
file_name="cover_letter.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
with dl_col2:
pdf_buffer = create_pdf(st.session_state["cover_letter"], "Cover Letter")
st.download_button(
label="Download as PDF",
data=pdf_buffer,
file_name="cover_letter.pdf",
mime="application/pdf",
)
Your app now generates tailored cover letters and lets users download them in two professional formats. Next up, you'll build the enhanced resume generator that rewrites the original resume with missing skills incorporated.
Build the Enhanced Resume Generator with Downloads
Your app can now analyze a resume and generate a tailored cover letter. The next logical step is to go further and rewrite the resume itself to better match the target role.
In this step, you will build an enhanced resume generator that takes your original resume, identifies what is missing, and produces an improved version. The AI will incorporate missing skills truthfully and strengthen existing bullet points to align with the job description.
In this step, get ready to:
- Write a function that sends resume data to Gemini and gets back an enhanced version.
- Reuse existing helper functions to generate downloadable PDF and DOCX files.
- Display the enhanced resume in the app with download buttons.
Write the enhanced resume generation function
The cover letter generator sends your resume and job description to Gemini and gets back a new document. The enhanced resume generator follows the same pattern, but with a more nuanced prompt. It needs to preserve your original resume structure while weaving in missing skills and strengthening existing content.
- In app.py, find the generate_cover_letter() function. Add the following new function directly below it (before create_docx):
def generate_enhanced_resume(resume_text, job_description, analysis):
prompt = f"""Rewrite and enhance the following resume to better match the target job description.
Original Resume:
{resume_text}
Job Description:
{job_description}
Missing skills to incorporate: {', '.join(analysis['missing_skills'])}
Matching skills to emphasize: {', '.join(analysis['matching_skills'])}
Requirements:
- Keep the same overall structure and sections as the original resume
- Naturally incorporate the missing skills where they can be truthfully implied from existing experience
- Strengthen bullet points that relate to matching skills with more impactful action verbs and quantifiable results
- Do not fabricate experience or credentials that are not implied by the original resume
- Add a tailored "Key Skills" or "Core Competencies" section near the top that highlights the most relevant skills for this role
- Keep the tone professional and concise
Write the complete enhanced resume text, nothing else."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
return response.text.strip()
What does this code do?
This function builds a detailed prompt that gives Gemini both the original resume and the job description, plus the specific missing and matching skills from the earlier analysis.
The prompt instructs the AI to preserve your real experience while naturally incorporating the missing skills. It also asks for a "Key Skills" section near the top, which is a common resume best practice for getting past applicant tracking systems.
Why does the prompt say not to fabricate?
Without that guardrail, the AI might invent job titles or certifications you never had. The instruction to only imply skills from existing experience keeps the output honest. You should always review the enhanced resume before submitting it anywhere.
- Save app.py.
Seeing a syntax error?
Make sure the function is placed after generate_cover_letter() and before create_docx(). The triple-quoted f-string must use matching triple double quotes at the start and end.
help me fix a syntax error in my generate_enhanced_resume function
Add the enhanced resume UI section with download buttons
You already built the create_docx() and create_pdf() helper functions in the previous step. They accept any text and a title, so you can reuse them here by passing "Resume" as the title instead of "Cover Letter".
- Scroll to the very bottom of app.py. Find the closing lines of the cover letter download section (the last st.download_button block inside dl_col2).
- Add the following code below that section, still inside the if "analysis" in st.session_state: block:
st.divider()
st.subheader("Generate Enhanced Resume")
if st.button("Generate Enhanced Resume"):
with st.spinner("Enhancing your resume..."):
enhanced_resume = generate_enhanced_resume(
st.session_state["resume_text"],
st.session_state["job_description"],
analysis
)
st.session_state["enhanced_resume"] = enhanced_resume
if "enhanced_resume" in st.session_state:
with st.expander("View Enhanced Resume", expanded=True):
st.write(st.session_state["enhanced_resume"])
st.write("**Download your enhanced resume:**")
res_col1, res_col2 = st.columns(2)
with res_col1:
docx_buffer = create_docx(st.session_state["enhanced_resume"], "Resume")
st.download_button(
label="Download Resume as DOCX",
data=docx_buffer,
file_name="enhanced_resume.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
with res_col2:
pdf_buffer = create_pdf(st.session_state["enhanced_resume"], "Resume")
st.download_button(
label="Download Resume as PDF",
data=pdf_buffer,
file_name="enhanced_resume.pdf",
mime="application/pdf",
)
What does this code do?
- st.divider() adds a visual separator between the cover letter section and this new section.
- The button triggers generate_enhanced_resume() and stores the result in st.session_state["enhanced_resume"] so it persists across reruns.
- st.expander() shows the enhanced resume in a collapsible section (open by default). This keeps the page tidy since enhanced resumes can be long.
- The download buttons reuse create_docx() and create_pdf() with "Resume" as the title, producing files named enhanced_resume.docx and enhanced_resume.pdf.
- Save app.py.
- Switch back to your browser where the Streamlit app is running. The page should refresh automatically.
- Upload a PDF resume, paste a job description, click Analyze Compatibility, then scroll down and click Generate Enhanced Resume.
- You should see the enhanced resume appear in an expanded section, with Download Resume as DOCX and Download Resume as PDF buttons below it.
Don't see the Enhanced Resume section?
The enhanced resume section only appears after you click Analyze Compatibility first. Make sure you have both a resume uploaded and a job description pasted before analyzing.
If you see an indentation error, double check that the new code block is indented at the same level as the cover letter section above it (one level inside the if "analysis" in st.session_state: block).
help me debug my enhanced resume section not showing up
Test the download buttons
- Click Download Resume as DOCX. Open the downloaded enhanced_resume.docx file and confirm it contains the enhanced resume text with a "Resume" heading.
- Click Download Resume as PDF. Open enhanced_resume.pdf and confirm the same content appears formatted as a PDF.
Reusable helper functions save time
Notice how you did not write any new PDF or DOCX generation code. The create_docx() and create_pdf() functions accept any text and title, making them reusable across different document types. This is a core principle of writing maintainable code.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and that both download buttons produce working files.
ⓧ I'd like to double check the full code
Here is the complete app.py file at this point in the project. Compare yours against it:
import streamlit as st
import pdfplumber
import io
import os
import json
from google import genai
from docx import Document
from fpdf import FPDF
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
MODEL_ID = "gemini-2.5-flash"
def extract_text_from_pdf(pdf_file):
text = ""
with pdfplumber.open(pdf_file) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
return text
def analyze_compatibility(resume_text, job_description):
prompt = f"""Analyze the compatibility between this resume and job description.
Resume:
{resume_text}
Job Description:
{job_description}
Respond in valid JSON format with these exact keys:
{{
"compatibility_score": <number from 0 to 100>,
"matching_skills": [<list of skills found in both resume and job description>],
"missing_skills": [<list of skills required by job but not found in resume>],
"summary": "<brief 2-3 sentence summary of the match>"
}}
Only respond with the JSON, no other text."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
response_text = response.text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
return json.loads(response_text)
def generate_cover_letter(resume_text, job_description, analysis):
prompt = f"""Write a professional cover letter for this job application.
Resume:
{resume_text}
Job Description:
{job_description}
Key matching skills: {', '.join(analysis['matching_skills'])}
Requirements:
- Professional tone, 3-4 paragraphs
- Highlight the matching skills naturally
- Show enthusiasm for the specific role
- Keep it under 400 words
- Do not include placeholder addresses or dates, start directly with the greeting
Write only the cover letter text, nothing else."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
return response.text.strip()
def generate_enhanced_resume(resume_text, job_description, analysis):
prompt = f"""Rewrite and enhance the following resume to better match the target job description.
Original Resume:
{resume_text}
Job Description:
{job_description}
Missing skills to incorporate: {', '.join(analysis['missing_skills'])}
Matching skills to emphasize: {', '.join(analysis['matching_skills'])}
Requirements:
- Keep the same overall structure and sections as the original resume
- Naturally incorporate the missing skills where they can be truthfully implied from existing experience
- Strengthen bullet points that relate to matching skills with more impactful action verbs and quantifiable results
- Do not fabricate experience or credentials that are not implied by the original resume
- Add a tailored "Key Skills" or "Core Competencies" section near the top that highlights the most relevant skills for this role
- Keep the tone professional and concise
Write the complete enhanced resume text, nothing else."""
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
return response.text.strip()
def create_docx(text, title):
doc = Document()
doc.add_heading(title, level=1)
for paragraph in text.split("\n\n"):
if paragraph.strip():
doc.add_paragraph(paragraph.strip())
buffer = io.BytesIO()
doc.save(buffer)
buffer.seek(0)
return buffer
def create_pdf(text, title):
pdf = FPDF()
pdf.add_page()
pdf.set_font("Helvetica", size=12)
pdf.cell(0, 10, title, new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(10)
pdf.set_font("Helvetica", size=11)
for paragraph in text.split("\n\n"):
if paragraph.strip():
pdf.multi_cell(0, 6, paragraph.strip())
pdf.ln(4)
buffer = io.BytesIO()
pdf.output(buffer)
buffer.seek(0)
return buffer
st.set_page_config(page_title="AI Resume Analyzer", page_icon="\U0001f4c4", layout="wide")
st.title("AI Resume Analyzer & Cover Letter Generator")
st.write("Upload your resume and paste a job description to get a compatibility score, tailored cover letter, and enhanced resume.")
col1, col2 = st.columns(2)
with col1:
st.subheader("Your Resume")
uploaded_file = st.file_uploader("Upload your resume (PDF)", type=["pdf"])
if uploaded_file:
resume_text = extract_text_from_pdf(uploaded_file)
with st.expander("View extracted resume text"):
st.text(resume_text)
else:
resume_text = None
with col2:
st.subheader("Job Description")
job_description = st.text_area(
"Paste the job description here",
height=300,
placeholder="Paste the full job description..."
)
if resume_text and job_description:
if st.button("Analyze Compatibility", type="primary"):
with st.spinner("Analyzing your resume against the job description..."):
analysis = analyze_compatibility(resume_text, job_description)
st.session_state["analysis"] = analysis
st.session_state["resume_text"] = resume_text
st.session_state["job_description"] = job_description
if "analysis" in st.session_state:
analysis = st.session_state["analysis"]
st.divider()
st.subheader("Compatibility Results")
score_col, summary_col = st.columns([1, 2])
with score_col:
st.metric("Match Score", f"{analysis['compatibility_score']}%")
with summary_col:
st.write(analysis["summary"])
skills_col1, skills_col2 = st.columns(2)
with skills_col1:
st.write("**Matching Skills**")
for skill in analysis["matching_skills"]:
st.write(f"- {skill}")
with skills_col2:
st.write("**Missing Skills**")
for skill in analysis["missing_skills"]:
st.write(f"- {skill}")
st.divider()
st.subheader("Generate Cover Letter")
if st.button("Generate Tailored Cover Letter"):
with st.spinner("Writing your cover letter..."):
cover_letter = generate_cover_letter(
st.session_state["resume_text"],
st.session_state["job_description"],
analysis
)
st.session_state["cover_letter"] = cover_letter
if "cover_letter" in st.session_state:
st.write(st.session_state["cover_letter"])
st.write("**Download your cover letter:**")
dl_col1, dl_col2 = st.columns(2)
with dl_col1:
docx_buffer = create_docx(st.session_state["cover_letter"], "Cover Letter")
st.download_button(
label="Download as DOCX",
data=docx_buffer,
file_name="cover_letter.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
with dl_col2:
pdf_buffer = create_pdf(st.session_state["cover_letter"], "Cover Letter")
st.download_button(
label="Download as PDF",
data=pdf_buffer,
file_name="cover_letter.pdf",
mime="application/pdf",
)
st.divider()
st.subheader("Generate Enhanced Resume")
if st.button("Generate Enhanced Resume"):
with st.spinner("Enhancing your resume..."):
enhanced_resume = generate_enhanced_resume(
st.session_state["resume_text"],
st.session_state["job_description"],
analysis
)
st.session_state["enhanced_resume"] = enhanced_resume
if "enhanced_resume" in st.session_state:
with st.expander("View Enhanced Resume", expanded=True):
st.write(st.session_state["enhanced_resume"])
st.write("**Download your enhanced resume:**")
res_col1, res_col2 = st.columns(2)
with res_col1:
docx_buffer = create_docx(st.session_state["enhanced_resume"], "Resume")
st.download_button(
label="Download Resume as DOCX",
data=docx_buffer,
file_name="enhanced_resume.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
with res_col2:
pdf_buffer = create_pdf(st.session_state["enhanced_resume"], "Resume")
st.download_button(
label="Download Resume as PDF",
data=pdf_buffer,
file_name="enhanced_resume.pdf",
mime="application/pdf",
)
Your app now handles the full job application workflow. It analyzes compatibility, writes a cover letter, and rewrites your resume to highlight the right skills. Next up, the Secret Mission takes this even further with AI-generated interview prep.
Secret mission
Add Interview Prep Questions
Your app already analyzes resumes and generates documents. But what about the next step in the job hunt? Add an Interview Prep feature where the AI generates likely interview questions based on the job description and your skill gaps, complete with suggested talking points for each.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and uses the Gemini API free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder on your Desktop (contains app.py and your virtual environment).
- GEMINI_API_KEY environment variable set in your terminal.
- Google AI Studio API key (free tier).
- Pip packages: streamlit, google-genai, pdfplumber, python-docx, fpdf2 (installed inside your virtual environment).
Keep everything running
No action needed. Choose this if you are still actively building or plan to use the app for your next job application.
- Your Streamlit app stays ready to run whenever you activate your virtual environment and run streamlit run app.py.
- Your GEMINI_API_KEY environment variable only lasts for the current terminal session. You will need to re-export it next time you open a new terminal.
- Your Google AI Studio API key remains active for future use.
Pause - I'll come back to this later
Shut down running processes to free up memory, but keep all files for later.
- Stop the Streamlit server by pressing Ctrl+C in the terminal where it is running.
- Deactivate your virtual environment by running:
deactivate
- Your project folder and all files remain on your Desktop, ready to pick back up anytime.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Stop the Streamlit server by pressing Ctrl+C in the terminal if it is still running.
- Delete your project folder from your Desktop. Navigate to your Desktop and remove the folder by running:
macOS
cd ~/Desktop
rm -rf resume-analyzer
Windows
cd ~\Desktop
rmdir /s /q resume-analyzer
- Unset the environment variable in your current terminal session by running:
macOS
unset GEMINI_API_KEY
Windows
Remove-Item Env:GEMINI_API_KEY
- Revoke your API key in Google AI Studio. Go to https://aistudio.google.com/apikey, find your key in the list, click the three-dot menu icon next to it, and select Delete.
Nice Work!
Nice Work!
Nice work! You just built a fully functional AI-powered job application assistant using nothing but Python.
You've learned how to:
- Build a complete Streamlit web application that uploads PDF resumes and analyzes them against job descriptions using the Google Gemini API.
- Extract text from PDF documents with pdfplumber and use AI prompt engineering to score skill compatibility, identify matching skills, and flag gaps.
- Generate tailored cover letters and enhanced resumes with AI, then convert them into downloadable PDF and DOCX files using fpdf2 and python-docx.
- Secret Mission: Added an AI-powered interview prep feature that generates role-specific questions and suggested talking points based on your resume and skill gaps.
Ready to quiz yourself?