AI-Powered Job Search Assistant

Build a web app that scores real job listings against your resume using AI.

Introduction

30 Second Summary

Job searching is exhausting. You scroll through hundreds of listings with no way to know which ones actually fit your background.

In this project, you will build a personal AI job search assistant using Python, Streamlit, and Google Gemini. Your app reads your resume, searches a live job board, and scores each listing against your profile.

What You'll Build

Drop your resume into the app and see a ranked list of real jobs appear, each scored against your skills with highlights showing where you match and where you have gaps.

By the end of this project, you'll have:

  • Upload a PDF resume and see your AI-extracted candidate profile with skills, experience, education, and suggested roles.
  • Search for jobs by role and location using real Adzuna job board data.
  • View each job scored and ranked with a match percentage, strong match highlights, skill gap warnings, and expandable details with a direct application link.
  • Secret Mission: Add a persistent job tracking system so you can manage your application pipeline directly in the app.

Are there any prerequisites?

Basic programming knowledge (variables, functions, loops) is all you need. Familiarity with Python syntax is helpful but not required. The project walks you through setting up every tool.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your goals. You're creating an AI-powered job search assistant that reads your real resume, finds matching jobs from a live job board, and scores each one against your profile.

Set Up Your Project and API Keys

Before you can build the job search assistant, you need a working Python environment with the right packages installed. You also need API keys that let your app talk to Google Gemini for AI analysis and the Adzuna job board for real listings.

By the end of this step, you will have a running Streamlit app in your browser. That confirms everything is wired up and ready for the real code.

In this step, get ready to:
  • Create a Python virtual environment and install project dependencies.
  • Get API keys for Google Gemini and Adzuna.
  • Run a Streamlit hello world app to verify your setup.
Create your project folder and install dependencies

A virtual environment keeps this project's packages separate from other Python projects on your machine. You will create one, activate it, and install the four packages your app needs.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir job-search-assistant
cd job-search-assistant

Your terminal prompt now shows job-search-assistant as the current directory.

  • Create your virtual environment by running this command:
python -m venv .venv
  • Activate the virtual environment. The command depends on your operating system:

macOS

  • Activate the virtual environment by running:
source .venv/bin/activate

This command also works on Linux.

Windows

  • If you are using Command Prompt, activate by running:
.venv\Scripts\activate.bat
  • If you are using PowerShell, run this instead:
.venv\Scripts\Activate.ps1

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 ran the activation command that matches your shell. On Windows, check whether your terminal is running Command Prompt or PowerShell by looking at the tab title in VS Code.

On Windows PowerShell, you may need to allow script execution first. Run this command, then try activating again:

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

On macOS, if python was not recognized in the earlier step, try using python3 instead. Then recreate the virtual environment with python3 -m venv .venv and activate it again.

Help me activate my Python virtual environment.

  • Install the four packages your project needs by running:
pip install streamlit pymupdf google-genai requests

What are these packages?

  • streamlit is a Python framework for building interactive web apps with minimal code.
  • pymupdf reads and extracts text from PDF files.
  • google-genai is Google's SDK for calling the Gemini AI models.
  • requests makes HTTP calls to web APIs like the Adzuna job board.

You should see a series of download and install messages ending with Successfully installed followed by a list of package names.

Seeing a pip install error?

Make sure (.venv) appears in your prompt before running pip install. If it does not, rerun the activation command above.

If you see a permissions error, you may have forgotten to activate the virtual environment. That means pip is trying to install globally instead of inside your project.

Help me fix my pip install error.

Get your Gemini API key

Your app needs an API key to authenticate with Google Gemini. You will grab one from Google AI Studio and store it in a configuration file that Streamlit knows how to read.

  • Open your browser and go to aistudio.google.com.
  • Sign in with your Google account if prompted.
  • Click Get API key in the left navigation to open the API keys page.
  • Click Create API key.
  • Copy the generated key. You will only see the full key once, so paste it somewhere safe before continuing.

Create API key button greyed out?

Google AI Studio automatically creates a default project for new users. If Create API key is greyed out or you see a permission error, try signing in with a personal Google account instead of a work or school account.

You can also try opening the page in an incognito or private browser window.

Help me create a Gemini API key.

Now store the key in a file that Streamlit can read. Streamlit uses a .streamlit/secrets.toml file for managing secrets.

  • In your terminal, create the .streamlit folder by running:
mkdir .streamlit
  • Open the job-search-assistant folder in VS Code so you can see your files in the sidebar. Click File in the menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the job-search-assistant folder, and click Open.

Why open the folder?

Opening the folder loads the project into VS Code's file explorer. This lets you see, create, and edit files in the sidebar instead of working only through the terminal.

VS Code may reopen in a new window. If your terminal closes, open a new one with Ctrl+` and reactivate your virtual environment using the activation command from earlier.

  • In the Explorer sidebar, expand the .streamlit folder.
  • Right-click the .streamlit folder and select New File.
  • Name the file secrets.toml and press Enter.
  • Paste the following into secrets.toml, replacing the placeholder with the Gemini key you copied:
GEMINI_API_KEY = "your-api-key-here"

What is secrets.toml?

Streamlit reads .streamlit/secrets.toml automatically when your app starts. Your code can access any value inside it with st.secrets["KEY_NAME"]. This keeps your API keys out of your application code.

  • Save secrets.toml with Cmd+S (macOS) or Ctrl+S (Windows).
Get your Adzuna API credentials and verify your setup

Adzuna is a job board aggregator that offers a free API for searching real job listings. You will register for credentials, add them to your secrets file, and run a quick hello world app to confirm everything works.

  • Open your browser and go to developer.adzuna.com.
  • Click Register in the navigation bar.
  • Fill in your details to create a free account.
  • After registration, you will receive an app_id and an app_key on your dashboard. Copy both values.

What are app_id and app_key?

Every request to the Adzuna API must include these two values so Adzuna knows who is making the call. The free tier gives you 2,500 requests per month, which is plenty for this project.

  • Switch back to VS Code and open .streamlit/secrets.toml.
  • Add the Adzuna credentials below the Gemini key so the full file looks like this:
GEMINI_API_KEY = "your-api-key-here"
ADZUNA_APP_ID = "your-app-id-here"
ADZUNA_APP_KEY = "your-app-key-here"
  • Replace each placeholder with the real values you copied from Google AI Studio and Adzuna.
  • Save the file.

✔️ Awesome, I've got everything!

Make sure you have saved secrets.toml with all three keys before continuing.

ⓧ I'd like to double check the full code

Your .streamlit/secrets.toml should contain exactly this (with your real values instead of the placeholders):

GEMINI_API_KEY = "your-api-key-here"
ADZUNA_APP_ID = "your-app-id-here"
ADZUNA_APP_KEY = "your-app-key-here"

Now create the app file that Streamlit will run.

  • In the VS Code Explorer sidebar, right-click the job-search-assistant folder and select New File.
  • Name it app.py and press Enter.
  • Paste the following code into app.py:
import streamlit as st

st.write("Hello world")

What does this code do?

  • import streamlit as st loads the Streamlit library so you can use its functions.
  • st.write("Hello world") renders the text "Hello world" on a web page that Streamlit serves for you.
  • Save app.py.

✔️ Awesome, I've got everything!

Make sure you have saved app.py before continuing.

ⓧ I'd like to double check the full code

Your app.py should contain exactly this:

import streamlit as st

st.write("Hello world")

Before you run the app, what do you think you will see in the browser?

  • Make sure your virtual environment is active (you see (.venv) in your prompt). If not, reactivate it using the command from earlier.
  • Run your Streamlit app by running this command:
streamlit run app.py

Streamlit starts a local web server and opens your default browser to a page showing "Hello world." Your terminal will display a local URL (usually http://localhost:8501). Leave this terminal running while you work.

What if my browser did not open?

Copy the URL from your terminal output (look for the line starting with Local URL) and paste it into your browser manually.

Seeing an error instead of Hello world?

Check that (.venv) appears in your terminal prompt. If it does not, Streamlit may not be installed in the active Python environment.

If you see a ModuleNotFoundError, rerun pip install streamlit with the virtual environment active, then try streamlit run app.py again.

Help me get Streamlit running.

Your environment is ready and Streamlit is running. Next up, you will build the resume upload feature and watch Gemini extract a structured profile from your PDF.

Build the Resume Upload and AI Profile Extraction

Your Streamlit app is running and showing "Hello world" in the browser. That proves your environment works, but the app does not do anything useful yet.

The first real feature is the foundation for everything else: reading a PDF resume and turning it into structured data. Without a structured profile, the app has nothing to match against jobs later. In this step, you will build a file uploader, extract text from a PDF with PyMuPDF, and send that text to Google Gemini for AI-powered analysis.

In this step, get ready to:
  • Extract text from a PDF resume using PyMuPDF.
  • Send resume text to Gemini and get a structured JSON profile back.
  • Display the candidate profile in a clean dashboard layout.
Build the resume upload interface

Before you can upload resumes, the app needs its configuration, imports, and a function that knows how to read PDF files.

  • In VS Code, open app.py.
  • Select all the existing code and replace it with the following:
import streamlit as st
import pymupdf
import requests
import json
import time
from google import genai
from google.genai import types

# --- Configuration ---
st.set_page_config(page_title="AI Job Search Assistant", layout="wide")

client = genai.Client(api_key=st.secrets["GEMINI_API_KEY"])

ADZUNA_APP_ID = st.secrets["ADZUNA_APP_ID"]
ADZUNA_APP_KEY = st.secrets["ADZUNA_APP_KEY"]


# --- Helper Functions ---
def extract_text_from_pdf(uploaded_file):
    """Extract text from an uploaded PDF file using PyMuPDF."""
    pdf_bytes = uploaded_file.read()
    doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
    text = ""
    for page in doc:
        text += page.get_text()
    doc.close()
    return text

What does this code do?

  • st.set_page_config() sets the browser tab title and switches the app to a wide layout, giving your dashboard more room.
  • genai.Client() creates a connection to the Gemini API using the key you stored in secrets.toml.
  • extract_text_from_pdf() reads the raw bytes of an uploaded PDF, opens it with PyMuPDF, loops through every page to grab the text, then returns it all as one string.
  • Save app.py.

The app does not have any visible UI elements yet, so you will not see content in the browser. The next code block adds the upload interface.

Now add the app title, the file upload widget, and the PDF extraction flow. Copy this code below the extract_text_from_pdf function in app.py:

# --- App Layout ---
st.title("AI Job Search Assistant")
st.caption("Upload your resume, find matching jobs, and get AI-powered match analysis.")

# --- Step 1: Resume Upload ---
st.header("1. Upload Your Resume")

uploaded_file = st.file_uploader("Upload your resume (PDF)", type=["pdf"])

if uploaded_file is not None:
    if "resume_text" not in st.session_state or st.session_state.get("uploaded_filename") != uploaded_file.name:
        with st.spinner("Extracting text from PDF..."):
            resume_text = extract_text_from_pdf(uploaded_file)
            st.session_state["resume_text"] = resume_text
            st.session_state["uploaded_filename"] = uploaded_file.name

        with st.expander("View raw extracted text"):
            st.text(st.session_state["resume_text"][:3000])

What does this code do?

  • st.file_uploader() renders a drag-and-drop upload widget that only accepts PDF files. It returns None until the learner uploads a file.
  • The nested if check prevents re-processing the same file on every Streamlit rerun. It only extracts text when a new file is uploaded.
  • st.session_state stores the extracted text so it survives Streamlit's automatic reruns. Without it, the text would be lost every time the page refreshes.
  • Save app.py and switch to your browser.
  • You should see a page titled "AI Job Search Assistant" with a file upload area.
  • Upload any PDF file you have handy (a resume, a report, anything with text).
  • Click the View raw extracted text expander. You should see the text content from your PDF.

Not seeing extracted text?

  • Make sure you uploaded a PDF file, not a Word document or image. The uploader only accepts .pdf files.
  • If the text is empty, your PDF might be image-based (a scanned document). Try a different PDF that contains selectable text.
  • Check the terminal running Streamlit for any error messages. A missing import or typo will show up there.

Help me debug my PDF upload in Streamlit.

Send the resume text to Gemini for structured extraction

Raw text from a PDF is messy. Job titles, skills, and education dates are all mixed together in one big string. To make that text useful, you need Gemini to read it and return a clean, structured JSON profile.

  • In app.py, find the extract_text_from_pdf function you just added.
  • Add the first part of the analyze_resume function directly below it. This part defines the prompt that tells Gemini exactly what to extract:
def analyze_resume(resume_text):
    """Send resume text to Gemini and get a structured candidate profile."""
    prompt = f"""Analyze the following resume and extract a structured candidate profile.

Resume text:
---
{resume_text[:8000]}
---

Return a JSON object with these fields:
- "name": the candidate's full name
- "email": email address (or empty string)
- "phone": phone number (or empty string)
- "location": city/state/country (or empty string)
- "summary": a 2-3 sentence professional summary
- "skills": a list of technical skills, tools, and technologies
- "soft_skills": a list of soft skills
- "education": a list of objects with "institution", "degree", "field", "graduation_date"
- "experience": a list of objects with "company", "title", "start_date", "end_date", "description"
- "projects": a list of objects with "name", "description", "technologies"
- "certifications": a list of certification names
- "experience_level": one of "Entry-level", "Mid-level", "Senior", "Lead/Principal"
- "suggested_roles": a list of 3-5 job titles that match this candidate's background
- "suggested_industries": a list of 2-3 industries this candidate is suited for

Be thorough. Extract ALL skills mentioned anywhere. Calculate experience level from work history dates.
If a field is missing from the resume, use an empty string or empty list."""

What is this prompt doing?

This is prompt engineering in action. You are giving Gemini a detailed instruction set that tells it exactly which fields to extract and what format each one should take.

resume_text[:8000] truncates the resume to 8,000 characters. This keeps the request within a reasonable size while capturing all the important content from most resumes.

The function is not complete yet. It has a prompt but no API call. Now add the code that sends this prompt to Gemini and parses the JSON response.

  • Add this code directly below the prompt's closing """, still inside the analyze_resume function:
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            temperature=0.1,
        ),
    )
    return json.loads(response.text)

What does this API call do?

  • response_mime_type="application/json" tells Gemini to return its answer as valid JSON instead of freeform text. This is called structured output.
  • temperature=0.1 keeps the output highly consistent. A low temperature means Gemini sticks closely to the facts in the resume instead of getting creative.
  • json.loads(response.text) converts the JSON string from Gemini into a Python dictionary you can work with.

The function is ready, but nothing calls it yet. You need to connect it to the upload flow so it runs after the PDF text is extracted.

  • Scroll down to the bottom of app.py and find this block:
        with st.expander("View raw extracted text"):
            st.text(st.session_state["resume_text"][:3000])
  • Add this code directly below the expander block, at the same indent level (two levels of indentation):
        with st.spinner("Analyzing resume with AI..."):
            profile = analyze_resume(st.session_state["resume_text"])
            st.session_state["profile"] = profile

What does this addition do?

This connects your analyze_resume function to the upload flow. After the PDF text is extracted and displayed, the app sends it to Gemini. The spinner shows "Analyzing resume with AI..." while Gemini processes the request. The structured profile is saved in st.session_state["profile"] so it persists across reruns.

  • Save app.py and switch to your browser.
  • Upload a PDF resume (your own resume or any resume-style PDF).
  • You should see two spinners appear in sequence: "Extracting text from PDF..." followed by "Analyzing resume with AI...".
  • Both spinners should complete without errors. The AI analysis may take a few seconds.

Seeing an API error?

  • Check that your GEMINI_API_KEY in .streamlit/secrets.toml is correct and has no extra spaces or quotes around it.
  • Make sure the API call code is indented inside the analyze_resume function. The response = line should have exactly 4 spaces of indentation.
  • If you see a rate limit error, wait 60 seconds and try again. The Gemini free tier allows 10 requests per minute.

Help me debug my Gemini API call in Streamlit.

Display the candidate profile

Gemini is returning a structured profile, but the app is not showing it yet. Time to build the display layer so you can actually see what the AI extracted from the resume.

  • In app.py, find the analyze_resume function.
  • Add this small utility function directly below it. It will be used in a later step to color-code job match scores:
def get_score_color(score):
    """Return a color based on match score."""
    if score >= 70:
        return "green"
    elif score >= 40:
        return "orange"
    else:
        return "red"

This helper is not called in this step, but placing it with the other functions keeps the file organized.

Now build the profile display. Scroll to the very bottom of app.py (after the st.session_state["profile"] line you just added) and add this code:

    if "profile" in st.session_state:
        profile = st.session_state["profile"]

        st.header("2. Your Candidate Profile")

        col1, col2, col3 = st.columns(3)
        with col1:
            st.metric("Experience Level", profile.get("experience_level", "N/A"))
        with col2:
            st.metric("Skills Found", len(profile.get("skills", [])))
        with col3:
            st.metric("Years of Experience", len(profile.get("experience", [])))

        with st.expander("Skills & Technologies", expanded=True):
            skills = profile.get("skills", [])
            if skills:
                st.write(" | ".join(skills))

What does this code do?

  • st.columns(3) creates three side-by-side columns for the metrics bar. Each column gets one summary metric.
  • st.metric() renders a large-format metric card. It displays a label and a value.
  • The skills expander starts open (expanded=True) so the learner immediately sees what Gemini extracted.
  • Save app.py and upload a PDF resume in the browser.
  • You should see three metric cards (Experience Level, Skills Found, Years of Experience) and a Skills & Technologies expander showing your extracted skills.

Profile section not appearing?

  • Make sure the if "profile" in st.session_state: line is indented inside the if uploaded_file is not None: block (one level of indentation, 4 spaces).
  • Try uploading a fresh PDF. If the profile was already cached from a failed attempt, a new file forces a fresh analysis.

Help me debug my candidate profile display.

The metrics look great, but a resume has much more than skills. Add the detailed profile sections for work experience, education, projects, and suggested roles.

  • Add this code directly below the Skills & Technologies expander block you just added:
        with st.expander("Work Experience"):
            for exp in profile.get("experience", []):
                st.markdown(
                    f"**{exp.get('title', 'N/A')}** at {exp.get('company', 'N/A')} "
                    f"({exp.get('start_date', '?')} - {exp.get('end_date', 'Present')})"
                )
                st.write(exp.get("description", ""))
                st.divider()

        with st.expander("Education"):
            for edu in profile.get("education", []):
                st.markdown(
                    f"**{edu.get('degree', 'N/A')}** in {edu.get('field', 'N/A')} - "
                    f"{edu.get('institution', 'N/A')} ({edu.get('graduation_date', 'N/A')})"
                )

        with st.expander("Projects"):
            for proj in profile.get("projects", []):
                st.markdown(f"**{proj.get('name', 'N/A')}**")
                st.write(proj.get("description", ""))
                techs = proj.get("technologies", [])
                if techs:
                    st.caption("Technologies: " + ", ".join(techs))
                st.divider()

        st.subheader("Suggested Roles")
        suggested_roles = profile.get("suggested_roles", [])
        if suggested_roles:
            st.write(", ".join(suggested_roles))

What do these sections display?

  • Work Experience shows each role with the job title, company, and dates. Each entry is separated by a divider.
  • Education lists degrees, fields of study, institutions, and graduation dates.
  • Projects shows each project with its description and the technologies used.
  • Suggested Roles displays the 3-5 job titles Gemini thinks best fit this candidate. You will use these to pre-fill the job search in the next step.

Before you run this, what do you expect to see when you upload your resume? Think about what sections your resume has and what Gemini will extract.

  • Save app.py and upload a PDF resume in the browser.

You should see a full candidate profile: three metrics at the top, then expandable sections for Skills, Work Experience, Education, and Projects, followed by a list of Suggested Roles at the bottom.

Sections showing but data is empty?

  • This usually means Gemini could not extract that section from your PDF. Expand the View raw extracted text section and check that the text contains the relevant information.
  • If you used a non-resume PDF for testing, try uploading an actual resume. The prompt is specifically designed to extract resume fields.

Help me understand why Gemini returned empty fields for my resume.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py.

ⓧ I'd like to double check the full code

Your app.py is now too long to show in one block. Scroll up through your file and confirm these sections appear in this order:

  • Imports (7 import lines including streamlit, pymupdf, requests, json, time, genai, types).
  • Configuration (set_page_config, genai.Client, ADZUNA_APP_ID, ADZUNA_APP_KEY).
  • Helper functions (extract_text_from_pdf, then analyze_resume with the full prompt and API call, then get_score_color).
  • App layout (title, caption, header, file_uploader).
  • Upload flow (PDF extraction spinner, raw text expander, AI analysis spinner).
  • Profile display (metrics row, Skills expander, Work Experience expander, Education expander, Projects expander, Suggested Roles).

Also confirm that .streamlit/secrets.toml still has your three keys: GEMINI_API_KEY, ADZUNA_APP_ID, and ADZUNA_APP_KEY.

You have a working resume reader that extracts a structured profile from any PDF. Next up, you will connect to the Adzuna job board and pull real job listings to match against this profile.

Connect to the Adzuna Job Search API

Your app can now read a resume and build a structured candidate profile with skills, experience, and suggested roles. The next challenge is finding real jobs to match against that profile.

The Adzuna job board API provides access to live job listings from across the web. In this step, you will connect to Adzuna, build a search form, and display real job results that the AI can score later.

In this step, get ready to:
  • Create a function that calls the Adzuna job search API.
  • Build a search form with job title and country inputs.
  • Display real job listings with titles, companies, and salaries.
Create the job search function

The Adzuna REST API accepts a GET request with your credentials and search terms, and returns a list of matching job postings as JSON. You need a function that wraps this call so the rest of the app can use it.

  • In app.py, scroll to the helper functions section and find the get_score_color() function.
  • Add the following search_jobs() function directly above get_score_color():
def search_jobs(query, country="us", results_per_page=15):
    """Search for jobs using the Adzuna API."""
    # Build the API URL with the country code
    url = f"https://api.adzuna.com/v1/api/jobs/{country}/search/1"
    # Set up query parameters for the search
    params = {
        "app_id": ADZUNA_APP_ID,
        "app_key": ADZUNA_APP_KEY,
        "results_per_page": results_per_page,
        "what": query,
        "content-type": "application/json",
    }
    # Send the GET request and return the job results
    response = requests.get(url, params=params)
    response.raise_for_status()
    return response.json().get("results", [])

What does this function do?

  • The URL includes a country path segment so Adzuna knows which regional job board to search.
  • The what parameter is Adzuna's keyword field. It filters results by job title or description.
  • raise_for_status() throws an error if the API returns a non-200 response, so bad credentials or network failures surface immediately instead of silently returning empty data.
  • Save app.py.
  • Check your browser. The Streamlit app should reload without any errors. The page looks the same as before because the function is defined but not called yet.

Seeing a syntax error on reload?

  • Make sure search_jobs() is at the same indentation level as the other functions (no extra indent). It is a top-level function, not nested inside anything.
  • Check that the function is placed between analyze_resume() and get_score_color(), not inside either function.

Help me fix a syntax error in my search_jobs function.

Build the search form

The learner needs a way to specify what kind of job to search for and which country to search in. A Streamlit form groups these inputs so they submit together with a single button click.

  • Scroll to the very bottom of app.py. Find these lines at the end of the file:
        st.subheader("Suggested Roles")
        suggested_roles = profile.get("suggested_roles", [])
        if suggested_roles:
            st.write(", ".join(suggested_roles))
  • Add the search form directly below those lines by copying this code:
        # --- Step 2: Job Search ---
        st.header("3. Search for Jobs")

        # Create a form so inputs submit together
        with st.form("search_form"):
            col1, col2 = st.columns([3, 1])
            with col1:
                # Pre-fill with the first AI-suggested role
                search_query = st.text_input(
                    "Job title",
                    value=suggested_roles[0] if suggested_roles else "",
                )
            with col2:
                # Map country codes to display names
                country = st.selectbox(
                    "Country",
                    options=["us", "gb", "ca", "au", "de", "fr", "in", "nz"],
                    format_func=lambda x: {
                        "us": "United States",
                        "gb": "United Kingdom",
                        "ca": "Canada",
                        "au": "Australia",
                        "de": "Germany",
                        "fr": "France",
                        "in": "India",
                        "nz": "New Zealand",
                    }.get(x, x),
                )
            search_submitted = st.form_submit_button("Search Jobs")

What does this form do?

  • st.form() groups inputs so they only send data when the learner clicks the submit button. Without a form, every keystroke would trigger a full Streamlit rerun.
  • The job title field pre-fills with suggested_roles[0], the first role Gemini suggested from the candidate profile. The learner can change it to any title they want.
  • The format_func maps short country codes (like us) to readable names in the dropdown, while the API still receives the code it expects.
  • Save app.py.
  • Switch to your browser. Make sure you have a resume uploaded so the profile section is visible.
  • Scroll down past the candidate profile. You should see a new "3. Search for Jobs" header with a text input (pre-filled with a suggested role), a country dropdown, and a Search Jobs button.

Don't see the search form?

  • The form only appears after you upload a resume and the profile loads. Upload a PDF first, then scroll down.
  • Check that the new code has 8 spaces of indentation (two levels). It sits inside the if "profile" in st.session_state: block. If it is at the wrong indent level, it will either run at the wrong time or cause an error.

Help me figure out why my search form isn't showing up.

Search for jobs and display the results

The form captures the search terms. Now you need the code that fires when the learner clicks Search Jobs: call the Adzuna API, store the results, and display each job listing.

  • In app.py, add this code directly below the search_submitted = st.form_submit_button("Search Jobs") line you just added, at the same indentation level as the with st.form block (8 spaces):
        if search_submitted and search_query:
            with st.spinner("Searching for jobs..."):
                # Call the Adzuna API and handle errors
                try:
                    jobs = search_jobs(search_query, country)
                    st.session_state["raw_jobs"] = jobs
                except Exception as e:
                    st.error(f"Error searching for jobs: {e}")
                    jobs = []

            # Display each job in an expandable card
            if jobs:
                for job in jobs:
                    with st.expander(f"{job.get('title', 'N/A')} - {job.get('company', {}).get('display_name', 'N/A')}"):
                        st.write(f"Location: {job.get('location', {}).get('display_name', 'N/A')}")
                        salary_min = job.get('salary_min')
                        salary_max = job.get('salary_max')
                        if salary_min or salary_max:
                            st.write(f"Salary: {salary_min} - {salary_max}")
                        st.write(f"Posted: {job.get('created', 'N/A')[:10]}")
                        st.write(job.get('description', 'N/A')[:300])
            else:
                st.warning("No jobs found. Try a different search term or country.")

What does this code do?

  • The try/except block catches API errors (bad credentials, network issues) and shows a friendly error message instead of crashing the app.
  • st.session_state["raw_jobs"] stores the results so they persist across Streamlit reruns. Without this, the job list would vanish every time the page re-renders.
  • Each job is displayed inside an st.expander() so the list stays compact. The learner can click any job to see its location, salary, posting date, and a snippet of the description.

Why only a snippet of the description?

The Adzuna API returns a short snippet of the job description, not the full posting. This is a limitation of the free tier. The snippet still gives Gemini enough context to score the match, and each listing includes a direct link to the full job page.

  • Save app.py.

Before you test, think about this: you have a search form and a function that calls the Adzuna API. When you click Search Jobs, do you think you will see real job listings or an error? Why?

  • Switch to your browser. Make sure a resume is uploaded and the profile is visible.
  • Scroll down to the search form. Leave the pre-filled job title as-is (or type a role like Software Engineer).
  • Pick a country from the dropdown.
  • Click Search Jobs.

You should see a list of 10 or more real job listings appear as expandable cards below the form. Each card shows a job title and company name. Click any card to expand it and see the location, salary range (if available), posting date, and a description snippet.

Seeing an error instead of job listings?

  • If the error mentions a 401 or 403 status, your Adzuna credentials are wrong. Open .streamlit/secrets.toml and confirm ADZUNA_APP_ID and ADZUNA_APP_KEY match the values from developer.adzuna.com exactly.
  • If you see "No jobs found", try a broader search term like Developer or switch the country to United States or United Kingdom for more results.
  • If the app crashes with a NameError mentioning search_jobs, make sure the function definition from the first substep is above the code that calls it, not below.

Help me debug my Adzuna API connection.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py.

ⓧ I'd like to double check the full code

The full app.py file is long, so scroll through your code and confirm these sections are present in this order:

  • Imports and configuration at the top (st.set_page_config, genai.Client, Adzuna credentials).
  • extract_text_from_pdf() function.
  • analyze_resume() function.
  • search_jobs() function (the new one from this step).
  • get_score_color() function.
  • App layout: title, file uploader, PDF extraction, AI profile display, suggested roles.
  • Search form with st.form("search_form") containing the job title input, country dropdown, and submit button.
  • Search handler: the if search_submitted block that calls the API, stores results in st.session_state["raw_jobs"], and displays each job in an expander.

You now have real job data flowing into your app from a live job board. Next up, you will send each job listing to Gemini for AI-powered scoring and find out which ones are the best matches for your profile.

AI-Powered Job Matching and Scoring

Your app can now pull real job listings from the Adzuna job board. But scrolling through a wall of 15 listings and guessing which ones fit your background is exactly what you were trying to avoid.

This is where AI turns raw data into actionable insight. You will send each job listing to Gemini alongside your candidate profile and get back a structured match score, skill analysis, and gap identification for every single job.

In this step, get ready to:
  • Build a scoring prompt that compares your profile against each job listing.
  • Score every job with Gemini and handle API rate limits.
  • Display scored results as color-coded job cards with match details.
Build the scoring prompt

The scoring function needs to take two inputs: your structured candidate profile and a single job listing. It will extract the key details from each job and format them into a prompt that Gemini can reason about.

  • In app.py, find the get_score_color() function.
  • Add a new score_job() function directly above it by copying the following code:
def score_job(candidate_profile, job):
    """Score a single job against the candidate profile using Gemini."""
    # Extract key details from the job listing into a readable string
    job_info = f"""Title: {job.get('title', 'N/A')}
Company: {job.get('company', {}).get('display_name', 'N/A')}
Location: {job.get('location', {}).get('display_name', 'N/A')}
Description: {job.get('description', 'N/A')}
Salary: {job.get('salary_min', 'N/A')} - {job.get('salary_max', 'N/A')}
Contract Type: {job.get('contract_type', 'N/A')}
Contract Time: {job.get('contract_time', 'N/A')}
Category: {job.get('category', {}).get('label', 'N/A')}"""

What does this code do?

The function signature takes two arguments: your full candidate profile (the JSON from resume analysis) and a single job dictionary from the Adzuna API.

The job_info string pulls out every useful field from the Adzuna response using .get() with 'N/A' fallbacks so the prompt never breaks if a field is missing.

  • Continue building the function by adding the prompt and API call directly below the job_info string:
    # Build the comparison prompt for Gemini
    prompt = f"""You are a job matching expert. Compare this candidate profile against this job listing.

CANDIDATE PROFILE:
{json.dumps(candidate_profile, indent=2)}

JOB LISTING:
{job_info}

Return a JSON object with:
- "match_score": integer 0-100 representing overall match percentage
- "strong_matches": list of specific qualifications the candidate clearly has
- "partial_matches": list of related or transferable skills
- "skill_gaps": list of requirements the candidate currently lacks
- "why_good_match": 2-3 sentence explanation of why this is or is not a good match
- "is_realistic": boolean, true if candidate has a realistic chance of getting hired

Be honest and specific. Do not inflate the score."""

    # Call Gemini with structured JSON output
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            temperature=0.1,
        ),
    )
    return json.loads(response.text)

What does the scoring prompt do?

The prompt sends the full candidate profile as JSON and the formatted job listing to Gemini, asking it to return six structured fields. The match_score gives a 0-100 number. The three list fields (strong_matches, partial_matches, skill_gaps) break down exactly where you stand.

Setting temperature=0.1 keeps Gemini's output consistent and factual. Higher temperatures would introduce randomness into the scores.

  • Save app.py.
  • Check your browser. Streamlit auto-reloads the app. You should see the same interface as before with no errors. The new function is defined but nothing calls it yet.

Seeing a syntax error after saving?

Make sure the second code block is indented inside the score_job() function. The prompt variable and response call should be at the same indent level as job_info.

Check that the triple-quoted strings (""") are properly closed. Each opening set needs a matching closing set.

Help me debug a syntax error in my score_job function.

Wire up the scoring loop

Right now, when the learner clicks Search Jobs, the app shows raw job listings in plain expanders. You need to replace that block with a loop that sends every job through your new score_job() function.

  • In app.py, scroll down to the if search_submitted and search_query: block.
  • Find the if jobs: branch inside it. It currently looks like this:
            if jobs:
                for job in jobs:
                    with st.expander(f"{job.get('title', 'N/A')} - {job.get('company', {}).get('display_name', 'N/A')}"):
                        st.write(f"Location: {job.get('location', {}).get('display_name', 'N/A')}")
                        salary_min = job.get('salary_min')
                        salary_max = job.get('salary_max')
                        if salary_min or salary_max:
                            st.write(f"Salary: {salary_min} - {salary_max}")
                        st.write(f"Posted: {job.get('created', 'N/A')[:10]}")
                        st.write(job.get('description', 'N/A')[:300])
            else:
                st.warning("No jobs found. Try a different search term or country.")
  • Delete everything inside the if jobs: branch (all 10 lines from for job in jobs: down to the last st.write line). Keep the if jobs: line itself and the else: branch.
  • Replace the deleted code with this scoring loop:
                st.success(f"Found {len(jobs)} jobs. Now scoring with AI...")
                scored_jobs = []
                progress_bar = st.progress(0)
                status_text = st.empty()

                for i, job in enumerate(jobs):
                    # Show which job is being scored
                    status_text.text(f"Scoring job {i + 1} of {len(jobs)}: {job.get('title', 'N/A')}")
                    try:
                        score_data = score_job(profile, job)
                        scored_jobs.append({"job": job, "score": score_data})
                    except Exception as e:
                        # If scoring fails for one job, keep going
                        scored_jobs.append({
                            "job": job,
                            "score": {
                                "match_score": 0,
                                "strong_matches": [],
                                "partial_matches": [],
                                "skill_gaps": ["Scoring failed"],
                                "why_good_match": f"Could not score: {e}",
                                "is_realistic": False,
                            },
                        })
                    progress_bar.progress((i + 1) / len(jobs))
                    # Respect the free tier rate limit of 10 RPM
                    if i < len(jobs) - 1:
                        time.sleep(1)

What does this code do?

  • st.progress(0) creates a progress bar that fills up as each job gets scored.
  • The try/except block means one failed API call does not crash the entire batch. Failed jobs get a score of 0 and keep going.
  • time.sleep(1) adds a 1-second pause between API calls. Gemini's free tier allows 10 requests per minute, so this keeps you safely under the limit.
  • Now add the sorting and storage code directly below the scoring loop (still inside the if jobs: branch, after the for loop ends):
                # Sort by match score, best matches first
                scored_jobs.sort(key=lambda x: x["score"].get("match_score", 0), reverse=True)
                st.session_state["scored_jobs"] = scored_jobs
                progress_bar.empty()
                status_text.empty()

Why store results in session state?

Streamlit reruns your entire script on every interaction. Storing the scored jobs in st.session_state["scored_jobs"] means the results persist when the learner clicks a filter or expands a card. Without session state, every click would trigger 15 more API calls.

  • Save app.py.
  • Switch to your browser. Upload a PDF resume if you have not already, then click Search Jobs.
  • You should see a green success banner, a progress bar filling up, and status text showing each job title as it gets scored. The process takes about 15-20 seconds.

Scoring seems stuck or shows an error?

If you see a rate limit error, the free tier allows 10 requests per minute. Wait 60 seconds and search again. The time.sleep(1) delay should prevent this, but rapid re-searching can stack up calls.

If individual jobs show a score of 0 with "Scoring failed", that is the error handling working correctly. Check your terminal for the specific error message.

Help me debug why Gemini job scoring is failing in my Streamlit app.

Display scored job cards

The scoring loop stores results in session state, but nothing renders them yet. You will build card-style containers that show each job's match score, company details, and a detailed skill breakdown.

  • In app.py, scroll down past the else: branch that shows "No jobs found." This is still inside the if "profile" in st.session_state: block.
  • Add the job card layout code below the entire if search_submitted and search_query: block:
        # Display scored results from session state
        if "scored_jobs" in st.session_state:
            scored_jobs = st.session_state["scored_jobs"]

            st.header("4. Job Matches")

            for item in scored_jobs:
                job = item["job"]
                score_data = item["score"]
                match_score = score_data.get("match_score", 0)

                with st.container(border=True):
                    col1, col2 = st.columns([4, 1])
                    with col1:
                        st.subheader(job.get("title", "N/A"))
                        st.caption(
                            f"{job.get('company', {}).get('display_name', 'N/A')} | "
                            f"{job.get('location', {}).get('display_name', 'N/A')}"
                        )
                    with col2:
                        st.metric("Match", f"{match_score}%")

                    salary_min = job.get("salary_min")
                    salary_max = job.get("salary_max")
                    if salary_min or salary_max:
                        st.caption(
                            f"Salary: ${salary_min:,.0f} - ${salary_max:,.0f}"
                            if salary_min and salary_max
                            else f"Salary: ${'${:,.0f}'.format(salary_min or salary_max)}"
                        )

What does this code do?

  • st.container(border=True) wraps each job in a bordered card instead of a plain list.
  • st.columns([4, 1]) creates a wide column for the title and a narrow column for the match score metric.
  • The salary display uses Python's :,.0f format specifier to show clean numbers like $85,000 instead of 85000.0.
  • Save app.py and check your browser.
  • If you already ran a search, you should see bordered job cards with titles, company names, and match score percentages. If you do not see them, click Search Jobs again.
  • Now add the detail expander inside each card. Place this code directly after the salary st.caption block, still inside the with st.container(border=True): block:
                    with st.expander("View Details"):
                        st.markdown(f"**Why You Match:** {score_data.get('why_good_match', 'N/A')}")

                        detail_col1, detail_col2 = st.columns(2)
                        with detail_col1:
                            st.markdown("**Strong Matches:**")
                            for match in score_data.get("strong_matches", []):
                                st.markdown(f"- :green[{match}]")
                            st.markdown("**Partial Matches:**")
                            for match in score_data.get("partial_matches", []):
                                st.markdown(f"- :orange[{match}]")
                        with detail_col2:
                            st.markdown("**Skill Gaps:**")
                            for gap in score_data.get("skill_gaps", []):
                                st.markdown(f"- :red[{gap}]")

                        st.markdown(f"**Description:** {job.get('description', 'N/A')}")
                        st.caption(
                            f"Posted: {job.get('created', 'N/A')[:10]} | "
                            f"Category: {job.get('category', {}).get('label', 'N/A')} | "
                            f"Contract: {job.get('contract_type', 'N/A')}"
                        )

                        redirect_url = job.get("redirect_url", "")
                        if redirect_url:
                            st.link_button("Apply Now", redirect_url)

What do the color-coded items show?

Streamlit's :green[], :orange[], and :red[] syntax renders colored text inline. Green highlights skills you clearly have. Orange shows transferable skills that partially match. Red flags gaps you would need to fill.

The st.link_button("Apply Now", redirect_url) creates a clickable button that opens the job posting page directly, so you can apply without leaving your workflow.

  • Save app.py.

Before you check the browser, what do you expect to see when you expand a job card? Think about what information Gemini is returning for each job.

  • Switch to your browser. If you need to re-run a search, upload your resume and click Search Jobs.
  • Click View Details on any job card. You should see the "Why You Match" explanation, green strong matches, orange partial matches, red skill gaps, the job description, and an Apply Now button.

Cards appear but details are empty?

Make sure the expander code is indented inside the with st.container(border=True): block. It should be at the same indent level as the col1, col2 line and the salary section above it.

If you see the cards but with 0% match scores and no details, check your terminal for Gemini API errors. The most common cause is a missing or invalid API key in .streamlit/secrets.toml.

Help me debug why my scored job cards are not displaying details correctly.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app.py and your scored job cards are displaying correctly in the browser.

ⓧ I'd like to double check the full code

Scroll through your app.py and confirm each of these sections is present, in order:

  • Imports and configuration at the top (st.set_page_config, genai.Client, Adzuna credentials).
  • extract_text_from_pdf() function.
  • analyze_resume() function.
  • search_jobs() function.
  • score_job() function (the new one from this step) with the job_info string, prompt, and client.models.generate_content() call.
  • get_score_color() function.
  • App layout: resume upload, profile display, search form.
  • Scoring loop inside if jobs: with st.progress(), time.sleep(1), and sorting by match_score.
  • Display section reading from st.session_state["scored_jobs"] with bordered containers, match metrics, salary captions, and a View Details expander containing color-coded skill analysis and an Apply Now button.

Your app now does something powerful. It takes a generic list of job postings and turns each one into a personalized match analysis. Next up, you will add filters, sorting, and summary metrics to turn this into a dashboard you would actually use for your job search.

Build the Results Dashboard

Your app now scores every job against your resume and ranks them by match percentage.

But scrolling through a flat list of scored cards is not how you would actually search for jobs. You need a way to see the big picture at a glance, filter out low matches, and re-sort by salary or recency. In this step, you will add a summary metrics bar, sidebar filters, and wire them into the existing job cards to create a real dashboard.

In this step, get ready to:
  • Add a summary metrics bar showing totals, averages, and top match at a glance.
  • Build sidebar filters for minimum score, realistic-only toggle, and sort order.
  • Connect the filters to your job cards so only matching results appear.
Add a summary metrics bar

Before the learner digs into individual job cards, a row of high-level numbers tells them whether the search was worth it. You will add four metrics right below the "4. Job Matches" header.

  • In app.py, scroll down to the dashboard section and find this line inside the if "scored_jobs" in st.session_state: block:
  • st.header("4. Job Matches")
  • Directly below that line, add the following summary metrics code:
            # Summary metrics
            scores = [j["score"].get("match_score", 0) for j in scored_jobs]
            high_match = sum(1 for s in scores if s >= 70)
            col1, col2, col3, col4 = st.columns(4)
            with col1:
                st.metric("Total Jobs", len(scored_jobs))
            with col2:
                st.metric("Avg Match", f"{sum(scores) // len(scores)}%")
            with col3:
                st.metric("High Match (70%+)", high_match)
            with col4:
                if scored_jobs:
                    st.metric("Top Match", f"{scores[0]}%")

What does this code do?

scores pulls every match score into a flat list so you can do quick math on it.

high_match counts how many jobs scored 70% or above. The four st.metric() calls render a row of cards showing total jobs, average score, high-match count, and the top score. Because scored_jobs is already sorted by score descending, scores[0] is always the highest.

  • Save app.py.
  • Switch to your browser and upload a resume, then search for jobs. After scoring completes, you should see four metric cards appear at the top of the results: Total Jobs, Avg Match, High Match (70%+), and Top Match.

Metrics not appearing?

  • Make sure the new code is inside the if "scored_jobs" in st.session_state: block and directly below st.header("4. Job Matches"). Indentation must match the surrounding code (12 spaces).
  • You need to run a new search so scored_jobs is populated in session state. The metrics only render after scoring has completed at least once.
  • help me figure out why my summary metrics are not showing up
Add sidebar filter controls

Streamlit's sidebar is a collapsible panel on the left side of the app. Placing filter controls there keeps them accessible without cluttering the main content area.

  • In app.py, place your cursor directly below the summary metrics block you just added (and above the existing for item in scored_jobs: loop).
  • Add the sidebar controls by pasting this code:
            # Filters
            with st.sidebar:
                st.header("Filters")
                min_score = st.slider("Minimum match score", 0, 100, 0)
                show_realistic_only = st.checkbox("Show only realistic matches")
                sort_option = st.selectbox(
                    "Sort by",
                    ["Match Score", "Salary (High to Low)", "Posting Date (Newest)"],
                )

What does this code do?

with st.sidebar: tells Streamlit to render everything inside it in the left sidebar panel instead of the main page area.

st.slider creates a draggable slider from 0 to 100, defaulting to 0 (show all jobs). st.checkbox adds a toggle for hiding unrealistic matches. st.selectbox gives a dropdown for choosing the sort order.

  • Save app.py and refresh your browser.
  • You should see a Filters panel appear in the left sidebar with a slider, a checkbox, and a dropdown. They do not affect the job list yet.

Sidebar not appearing?

  • The sidebar only appears when there is content inside it. Make sure you have scored jobs loaded in session state (run a search first).
  • Check that the with st.sidebar: block is properly indented inside the if "scored_jobs" in st.session_state: block.
  • If the sidebar appears collapsed, hover over the left edge of the page and click the arrow to expand it.
  • help me debug why my Streamlit sidebar is not showing up

Now you need the filtering and sorting logic that reads the sidebar values and narrows the job list.

  • Directly below the sidebar block you just added, paste this filtering and sorting logic:
            filtered_jobs = [
                j for j in scored_jobs if j["score"].get("match_score", 0) >= min_score
            ]
            if show_realistic_only:
                filtered_jobs = [j for j in filtered_jobs if j["score"].get("is_realistic", False)]

            if sort_option == "Salary (High to Low)":
                filtered_jobs.sort(
                    key=lambda x: x["job"].get("salary_max", 0) or 0, reverse=True
                )
            elif sort_option == "Posting Date (Newest)":
                filtered_jobs.sort(
                    key=lambda x: x["job"].get("created", ""), reverse=True
                )

            st.caption(f"Showing {len(filtered_jobs)} of {len(scored_jobs)} jobs")

What does this code do?

filtered_jobs starts as a copy of scored_jobs but only keeps jobs whose match score meets the slider threshold. If the checkbox is checked, it further removes any job where is_realistic is False.

The if sort_option branches re-sort the filtered list by salary or posting date. If the default "Match Score" is selected, no re-sort happens because the list is already sorted by score from the previous step.

st.caption prints a small line like "Showing 8 of 15 jobs" so the learner knows how many results passed the filters.

Connect filters to your job cards

The filtering logic creates a filtered_jobs list, but the existing loop still iterates over the unfiltered scored_jobs. You need to update the loop to use the filtered list.

  • In app.py, find this block of code below the filtering logic:
            for item in scored_jobs:
                job = item["job"]
                score_data = item["score"]
                match_score = score_data.get("match_score", 0)
  • Replace scored_jobs with filtered_jobs in the for line, and add a new line color = get_score_color(match_score) below match_score, so the block now looks like this:
            for item in filtered_jobs:
                job = item["job"]
                score_data = item["score"]
                match_score = score_data.get("match_score", 0)
                color = get_score_color(match_score)

Before you test this, what do you think will happen when you drag the minimum score slider to 70?

  • Save app.py and refresh your browser.
  • Upload your resume and search for jobs. After scoring completes, drag the Minimum match score slider in the sidebar to 70.
  • The job list should shrink to show only high-match jobs. The caption should read something like "Showing 3 of 15 jobs".
  • Try changing the Sort by dropdown to Salary (High to Low) and confirm the card order changes.
  • Expand a job card and confirm you can still see the Why You Match explanation, skill analysis, and Apply Now button.

Filters not changing the results?

  • Double check that the for loop now says for item in filtered_jobs: and not for item in scored_jobs:. If it still says scored_jobs, the filters have no effect.
  • Make sure the filtering logic block sits between the sidebar block and the for loop. If it is inside the with st.sidebar: block, the caption will render in the sidebar instead of the main page.
  • help me figure out why my sidebar filters are not filtering the job list

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py and that your dashboard shows metrics, sidebar filters, and filtered job cards.

ⓧ I'd like to double check the full code

The full app.py is too long to display in one block. Scroll through your file and confirm these sections are present, in order:

  • Imports (streamlit, pymupdf, requests, json, time, google.genai, google.genai.types).
  • Configuration: st.set_page_config, genai.Client, Adzuna credentials.
  • Helper functions: extract_text_from_pdf, analyze_resume, search_jobs, score_job, get_score_color.
  • App layout: title, file uploader, profile display, search form, scoring loop.
  • Dashboard section (inside if "scored_jobs" in st.session_state): header, summary metrics (4 columns), sidebar filters, filtering/sorting logic, st.caption, and the for item in filtered_jobs: loop with color = get_score_color(match_score).

Secret mission

Add Job Tracking

Finding matching jobs is only half the battle. Can you add a tracking system that lets you manage your entire application pipeline directly in the app, with statuses that persist between sessions?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your machine and uses only free-tier APIs. There are no ongoing costs.

Resources you used:

  • The job-search-assistant project folder on your Desktop, including app.py, .streamlit/secrets.toml, tracked_jobs.json, and the .venv virtual environment.
  • Gemini API key in Google AI Studio (free tier, no charges when idle).
  • Adzuna API credentials on the Adzuna developer portal (free tier, no charges when idle).

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Keep everything running

No action needed. Choose this if you are still actively building or plan to keep using your job search assistant.

  • Your app runs locally and both APIs are free tier. Nothing is being charged while idle.
  • Your API keys for Gemini and Adzuna remain active and ready whenever you want to run the app again.
  • Your tracked_jobs.json file preserves all your job tracking data between sessions.

Pause - I'll come back to this later

Both APIs are free and cost nothing when idle, so there is nothing to pause on the API side. If the Streamlit server is still running, stop it to free up your terminal.

  • Press Ctrl+C in the terminal where Streamlit is running to stop the server.
  • Your project files stay on your Desktop in the job-search-assistant folder for when you come back.
  • To restart, open a terminal, activate your virtual environment, and run streamlit run app.py again.

Delete - I don't want to use this again

Remove all project files and optionally revoke your API keys to start completely fresh.

  • Press Ctrl+C in the terminal where Streamlit is running to stop the server.
  • Delete the project folder by running this command on macOS/Linux:
rm -rf ~/Desktop/job-search-assistant
  • On Windows, run this command in Command Prompt instead:
rmdir /s /q %USERPROFILE%\Desktop\job-search-assistant

This removes your app.py, .streamlit/secrets.toml, tracked_jobs.json, and the .venv virtual environment all at once.

Optionally, revoke your API keys so they can no longer be used.

  • For Gemini, visit aistudio.google.com and click Get API Key to see your keys. Select the key you created for this project and delete it.
  • For Adzuna, visit developer.adzuna.com and sign in to your account to manage or remove your API credentials.

No rush on API keys

Both Gemini and Adzuna free tiers cost nothing when idle. Revoking is a good security practice, but leaving them active will not result in any charges.

Nice Work!

Nice Work!

You just built a fully functional AI-powered job search assistant from scratch. You went from a blank Python file to a live web app that reads your resume, understands your skills, searches real job boards, and scores every listing against your profile.

You've learned how to:

  • Build a Streamlit web app in Python that extracts structured data from a PDF resume using PyMuPDF and Google Gemini.
  • Integrate the Adzuna job search API to pull real, live job listings and engineer prompts that make Gemini return structured JSON with match scores, skill analysis, and gap identification.
  • Create an interactive dashboard with filtering, sorting, color-coded match scores, and expandable job cards with direct application links.
  • Secret Mission: Added a persistent job tracking system with status updates that survives app restarts.

Ready to quiz yourself?