Build an AI Job Match Assistant
Use Python, Gemini AI, and REST APIs to score job listings against your resume.
Introduction
30 Second Summary
Job hunting is exhausting. You scroll through hundreds of listings, mentally comparing each one to your resume, only to realize you have wasted an hour on roles that were never a good fit.
In this project, you will build a Python tool that reads your PDF resume, fetches real job listings from the internet, uses Google Gemini AI to score each job's fit, stores results in a database, and generates a hand-coded interactive web page where you can browse your top matches.
What You'll Build
A command-line Python tool that fetches live job listings from the Adzuna REST API, scores them against your resume using LLM prompt engineering, stores everything in a SQLite database, and generates an interactive HTML/CSS/JavaScript dashboard you open in your browser.
By the end of this project, you'll have:
- A working Python tool that fetches real job listings from the Adzuna API, scores them against your CV using Google Gemini AI, and stores results in a SQLite database.
- A hand-coded interactive HTML/CSS/JS web page (dashboard) that displays your top job matches with color-coded scores, filter controls, and direct apply links.
- A reusable script you can run daily to get fresh, personalized job recommendations without duplicate entries.
- A Secret Mission upgrade: a "Mark as Applied" button on each job card that talks to a Python micro web server to update the database in real time.
Are there any prerequisites?
You need basic Python knowledge, a Google account (for your free Gemini API key), and a resume or CV saved as a PDF file. No credit card is required for any of the tools in this project.
Set Up Your Project and Get API Keys
Every professional Python project starts with a clean foundation. Before writing any application code, you need an isolated workspace for your project's libraries and API keys to connect to external services.
In this step, you will create a virtual environment, install three Python libraries, and get free API keys from Gemini and Adzuna.
In this step, get ready to:
- Create a virtual environment and install the required Python libraries.
- Get a free Google Gemini API key for AI-powered job scoring.
- Get a free Adzuna API key for fetching live job listings.
Create the project folder and install libraries
- 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) on your keyboard.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir job-match-ai
cd job-match-ai
- Confirm you are in the right folder by running:
pwd
You should see a path ending in job-match-ai, like /Users/yourname/Desktop/job-match-ai.
Now open this folder in VS Code so the editor knows where your project lives.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the job-match-ai folder.
- Click Open.
- If VS Code asks "Do you trust the authors of the files in this folder?", click Yes, I trust the authors.
VS Code reloads with your project folder as the workspace. Your previous terminal session is gone.
- Open a fresh terminal by pressing Ctrl+` (backtick).
This terminal automatically starts inside your job-match-ai folder. Next, you need to create a virtual environment before installing any libraries.
What is a virtual environment?
A virtual environment is an isolated Python workspace with its own set of installed packages. Different projects can have different library versions without conflicting with each other.
This is a common interview question. The answer: you create one with python3 -m venv venv and activate it before working on the project.
macOS
- Create a virtual environment by running this command:
python3 -m venv venv
- Activate the virtual environment by running:
source venv/bin/activate
Windows
- Create a virtual environment by running this command:
python -m venv venv
- Activate the virtual environment by running:
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
- Make sure you ran the activate command, not just the venv creation command. Creating the environment and activating it are two separate steps.
- On Windows, if you see a permissions error about running scripts, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try activating again.
- My virtual environment won't activate. How do I fix this?
- Install the three libraries your project needs by running:
pip install google-genai pdfplumber requests
What do these libraries do?
- google-genai is the official Python SDK for Google's Gemini AI models.
- pdfplumber extracts text from PDF files.
- requests makes HTTP requests to web APIs.
You should see Successfully installed followed by a list of package names near the end of the output.
Seeing an installation error?
- Make sure (venv) is showing in your terminal prompt. If it is not, activate the virtual environment first.
- Check your internet connection. pip needs to download packages from PyPI (Python Package Index).
- I'm getting an error installing Python packages with pip. How do I fix this?
- Lock your dependencies into a requirements.txt file by running:
pip freeze > requirements.txt
What is a requirements.txt file?
pip is Python's package manager. It installs libraries from PyPI.
A requirements.txt file lists every library your project uses with exact version numbers. Anyone can recreate your environment by running pip install -r requirements.txt. This is standard practice for professional Python projects.
- Confirm the file was created by running:
ls
You should see requirements.txt and a venv folder in the output.
Get your free Gemini API key
Google Gemini is the AI model your tool will use to score how well each job matches your resume. You need a free API key to access it. No credit card is required.
- Open https://aistudio.google.com/apikey in your browser.
- Sign in with your Google account.
- Click Create API Key.
- Select any Google Cloud project from the list, or let it create one for you.
- Copy the generated API key and save it somewhere safe, like a text file on your Desktop.
Pro tip: keep your API key safe
Do not share your API key publicly or commit it to GitHub. Anyone with your key can use your Gemini quota. You will store it in an environment variable later, not in your code.
Get your free Adzuna API key
Adzuna is a job search platform that provides a free REST API for developers. Your tool will use it to fetch real job listings from India's job market.
- Open https://developer.adzuna.com/signup in your browser.
- Fill in the registration form with your username, email, and a password.
- For the Application of the Adzuna API field, select Personal or academic research.
- Complete the remaining fields and submit the form.
- After registering, you will be taken to your developer dashboard.
- Find your Application ID and Application Key on the dashboard page.
- Copy both values and save them alongside your Gemini API key.
What are these two Adzuna credentials?
Adzuna gives you an app_id (your application identifier) and an app_key (your secret key). You need both to authenticate your API requests. These are separate from your Gemini API key.
Having trouble signing up for Adzuna?
- Make sure you are on the developer portal at developer.adzuna.com, not the main Adzuna job search site.
- Check your email for a verification link if the dashboard does not appear immediately after signup.
- I can't find my Adzuna app_id and app_key after signing up. Where do I look?
Your environment is ready and your API keys are in hand. Next up, you will create the configuration file and build the module that reads your resume PDF.
Build the Configuration and CV Parser
Your development environment is set up and your API keys are ready. Now it's time to tell your assistant what to look for and give it access to your resume.
In this step, you'll create a configuration file that stores your job preferences and API credentials. You'll also build a module that extracts readable text from your PDF resume.
In this step, get ready to:
- Create config.py with your target role, location, and API key settings.
- Build cv_parser.py to extract text from a PDF resume.
- Test the parser with your actual CV.
Create config.py with your job search preferences and API keys
Every value your assistant needs belongs in one place. A dedicated configuration file means you can switch your target role from Junior Python Developer to Data Analyst without touching any logic.
- In VS Code, create a new file called config.py inside your job-match-ai folder.
- Add the following code to set up your job search preferences:
import os
# Job search preferences - change these to match your target role
TARGET_ROLE = os.environ.get("JOB_ROLE", "Junior Python Developer")
LOCATION = os.environ.get("JOB_LOCATION", "Mumbai")
EXPERIENCE_LEVEL = "Fresher / 0-1 years"
COUNTRY = "in" # Adzuna country code for India
MAX_RESULTS = 10 # Number of jobs to fetch per search
CV_PATH = os.environ.get("CV_PATH", "resume.pdf")
What does this code do?
- os.environ.get() reads a value from your system's environment variables. If the variable is not set, it falls back to the default value you provide.
- TARGET_ROLE and LOCATION define what jobs to search for. COUNTRY is set to "in" for India's job market on Adzuna.
- MAX_RESULTS limits how many jobs to fetch per search. Starting with 10 keeps API calls fast while you test.
- Save config.py.
You won't see any output yet. The next section adds the API key loading and a check you can run.
- In config.py, add the following code below the CV_PATH line:
# API keys loaded from environment variables for security
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "")
# Warn the user if API keys are missing
if not GEMINI_API_KEY:
print("Warning: GEMINI_API_KEY not set. Run: export GEMINI_API_KEY='your-key'")
if not ADZUNA_APP_ID or not ADZUNA_APP_KEY:
print("Warning: Adzuna keys not set. Run: export ADZUNA_APP_ID='your-id' and export ADZUNA_APP_KEY='your-key'")
What does this code do?
- os.environ.get("GEMINI_API_KEY", "") reads your Gemini API key from the environment. The empty string default means the variable stays blank if you have not exported it yet.
- The if not checks at the bottom print a warning message if any API key is missing. This catches configuration mistakes early before the rest of the pipeline runs.
- Save config.py.
- Verify the file runs without errors by running this command:
python config.py
You should see two warning messages about missing API keys. This is expected because you have not exported them as environment variables yet.
Seeing a SyntaxError?
- Check that every os.environ.get() call has matching parentheses and quotes.
- Make sure the if not checks are at the top level of the file, not indented inside a function or class.
- Help me fix a SyntaxError in my config.py file.
Awesome, I've got everything!
Double check you've saved config.py before moving on.
I'd like to double check the full code
import os
TARGET_ROLE = os.environ.get("JOB_ROLE", "Junior Python Developer")
LOCATION = os.environ.get("JOB_LOCATION", "Mumbai")
EXPERIENCE_LEVEL = "Fresher / 0-1 years"
COUNTRY = "in"
MAX_RESULTS = 10
CV_PATH = os.environ.get("CV_PATH", "resume.pdf")
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "")
if not GEMINI_API_KEY:
print("Warning: GEMINI_API_KEY not set. Run: export GEMINI_API_KEY='your-key'")
if not ADZUNA_APP_ID or not ADZUNA_APP_KEY:
print("Warning: Adzuna keys not set. Run: export ADZUNA_APP_ID='your-id' and export ADZUNA_APP_KEY='your-key'")
Why should you never hardcode API keys?
Hardcoded secrets get committed to version control systems like Git. If your repository is public, anyone can see and use your keys.
Environment variables keep secrets outside your code. You set them in your terminal session, and your Python code reads them at runtime using os.environ.get().
Create cv_parser.py to extract text from your resume
Your assistant needs to read your resume to compare your skills against job listings. The pdfplumber library handles PDF text extraction, pulling readable text from each page of your document.
- In VS Code, create a new file called cv_parser.py inside your job-match-ai folder.
- Add the following code for the text extraction function:
import pdfplumber
def extract_cv_text(pdf_path):
"""Extract all text from a PDF file."""
text_parts = [] # Collect text from each page
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text: # Skip pages with no extractable text
text_parts.append(page_text)
# Join all pages into one string separated by newlines
return "\n".join(text_parts)
What does this code do?
- pdfplumber.open(pdf_path) opens the PDF file and gives you access to each page.
- The for page in pdf.pages loop visits every page in the document.
- page.extract_text() pulls readable text from a single page. The if page_text check skips any pages that return no text.
- "\n".join(text_parts) combines all pages into one string with newlines between them.
- Save cv_parser.py.
You won't see any output yet. The next section adds a test you can run to verify the parser works.
- In cv_parser.py, add the following code below the extract_cv_text function:
if __name__ == "__main__":
import config
cv_text = extract_cv_text(config.CV_PATH)
print(f"Extracted {len(cv_text)} characters from CV.")
print("--- First 500 characters ---")
print(cv_text[:500]) # Preview to verify extraction works
What does __name__ == "__main__" mean?
This line checks whether the file is being run directly (like python cv_parser.py) or imported by another file.
When you run the file directly, __name__ equals "__main__" and the test code executes. When another file imports cv_parser, this block is skipped entirely.
- Save cv_parser.py.
What is the difference between a module and a script?
A module is a Python file designed to be imported by other files. It contains functions or variables meant to be reused. cv_parser.py is a module.
A script is a Python file designed to be run directly. main.py (which you will create later) is a script. The if __name__ == "__main__" pattern lets a single file work as both.
Awesome, I've got everything!
Double check you've saved cv_parser.py before moving on.
I'd like to double check the full code
import pdfplumber
def extract_cv_text(pdf_path):
"""Extract all text from a PDF file."""
text_parts = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text_parts.append(page_text)
return "\n".join(text_parts)
if __name__ == "__main__":
import config
cv_text = extract_cv_text(config.CV_PATH)
print(f"Extracted {len(cv_text)} characters from CV.")
print("--- First 500 characters ---")
print(cv_text[:500])
Extra for Experts: What is the difference between a list and a dictionary?
A list is an ordered collection of items accessed by position (index). In cv_parser.py, text_parts is a list that collects text from each page.
A dictionary is a collection of key-value pairs accessed by key. In the next step, you will use dictionaries to store details about each job, like {"title": "Python Dev", "company": "Acme"}.
Place your CV and test the parser
The parser needs a real PDF to extract text from. You will use your own resume for this test.
- Find your resume PDF on your computer.
- Copy or move it into your job-match-ai folder.
- Rename it to resume.pdf so it matches the default path in config.py.
- Test the parser by running this command:
python cv_parser.py
You should see a character count followed by the first 500 characters of your resume text. The output proves that pdfplumber can read your PDF successfully.
Not seeing readable text?
- If you see FileNotFoundError, make sure your resume is named resume.pdf and is inside the job-match-ai folder.
- If the text looks garbled or empty, your PDF might be scanned (image-based) rather than text-based. Try re-saving your resume from a word processor like Google Docs or Microsoft Word as a fresh PDF.
- Help me troubleshoot my cv_parser.py output.
Your configuration is set and your CV parser is working. Next up, you'll connect to the Adzuna API and fetch real job listings from India's job market.
Fetch Live Jobs from the Adzuna API
Your configuration and CV parser are ready to go. Now your tool needs something to actually work with: real job listings from the internet.
In this step, you'll build the module that reaches out to the Adzuna API, pulls back live job data as JSON, and organizes it into a clean format your other modules can use.
In this step, get ready to:
- Build a function that sends a search request to the Adzuna API.
- Process the raw API response into clean, structured job dictionaries.
- Test the fetcher by running it standalone and printing real job listings.
Create the job fetcher function
The job fetcher sends an HTTP request to Adzuna's search endpoint and gets back a list of matching jobs. This is the same pattern used in every modern application that talks to an external service.
What is a REST API?
REST (Representational State Transfer) is a standard way for applications to communicate over the internet using URLs and HTTP methods.
You send a request to a URL (called an endpoint), and the server sends back data in JSON format. GET retrieves data. POST sends data to create something. This project uses GET to search for jobs.
- Create a new file called job_fetcher.py in your job-match-ai folder.
- Add the imports and the fetch_jobs() function by copying this code into the file:
import requests
import config
def fetch_jobs(role=None, location=None, max_results=None):
"""Fetch job listings from the Adzuna API."""
# Use config defaults if no arguments are provided
role = role or config.TARGET_ROLE
location = location or config.LOCATION
max_results = max_results or config.MAX_RESULTS
# Build the Adzuna search endpoint URL with the country code in the path
url = f"http://api.adzuna.com/v1/api/jobs/{config.COUNTRY}/search/1"
# Set up query parameters for the API request
params = {
"app_id": config.ADZUNA_APP_ID,
"app_key": config.ADZUNA_APP_KEY,
"what": role,
"where": location,
"results_per_page": max_results,
"content-type": "application/json",
}
# Send the GET request and parse the JSON response
response = requests.get(url, params=params)
response.raise_for_status() # Raise an error for bad status codes
data = response.json()
return process_results(data.get("results", []))
What does this code do?
- The function accepts optional arguments for role, location, and max_results. If you don't pass them, it uses the defaults from config.py.
- The URL contains a path parameter (config.COUNTRY = "in") baked into the URL path itself. The params dictionary holds query parameters that go after the ? in the URL to filter results.
- requests.get() sends the HTTP GET request. raise_for_status() automatically throws an error if the response code is 400, 401, 404, or any other failure. This saves you from checking manually.
- response.json() parses the JSON text into a Python dictionary. The actual job listings live inside the "results" key of that dictionary.
- The last line calls process_results(), which you'll add in the next substep.
What are HTTP status codes?
Every HTTP response includes a status code that tells you what happened. 200 means success. 401 means unauthorized (wrong API key). 429 means you sent too many requests (rate limited).
raise_for_status() checks the code for you. If it's anything other than a success, it raises an exception so your program doesn't silently continue with bad data.
You won't see a change yet. The next substep adds the process_results() function that this code calls.
Process the raw API response
The Adzuna API returns each job as a nested JSON object with inconsistent field names. Some jobs have salary data, others don't. Some have company names nested inside a company object, while others might be missing that field entirely.
The process_results() function cleans all this up into a consistent format so the rest of your code never has to worry about missing data.
- In job_fetcher.py, add the process_results() function below the fetch_jobs() function:
def process_results(raw_results):
"""Convert raw API results into clean job dictionaries."""
jobs = []
for item in raw_results:
# Extract each field, using .get() for safe access with defaults
job = {
"job_id": str(item.get("id", "")),
"title": item.get("title", "Unknown Title"),
"company": item.get("company", {}).get("display_name", "Unknown Company"),
"location": item.get("location", {}).get("display_name", "Unknown Location"),
"description": item.get("description", ""),
"apply_url": item.get("redirect_url", ""),
"salary_min": item.get("salary_min"),
"salary_max": item.get("salary_max"),
}
jobs.append(job)
return jobs
What does this code do?
- The function loops through each raw job from the API and builds a clean dictionary with consistent keys like job_id, title, company, and apply_url.
- .get() is the safe way to read dictionary keys. If the key doesn't exist, it returns the default value you provide instead of crashing with a KeyError.
- For nested fields like company.display_name, we chain two .get() calls. The first returns an empty dictionary as its default, so the second .get() still works even if the parent key is missing.
You won't see a change yet. The next substep adds a test block so you can run the file and see real results.
Test the fetcher with real job data
Time to see if your fetcher actually works. You'll add a test block at the bottom of the file that runs only when you execute the file directly.
- Add this test block at the very bottom of job_fetcher.py, below the process_results() function:
if __name__ == "__main__":
jobs = fetch_jobs()
print(f"Found {len(jobs)} jobs:")
for job in jobs:
print(f" - {job['title']} at {job['company']} ({job['location']})")
What does if __name__ == '__main__' mean?
This checks whether the file is being run directly (like python job_fetcher.py) or being imported by another file. Code inside this block only runs when you execute the file directly.
When main.py imports job_fetcher later, this test code will not run. It gives you a safe way to test each module independently.
Awesome, I've got everything!
Double check you've saved job_fetcher.py before moving on.
I'd like to double check the full code
import requests
import config
def fetch_jobs(role=None, location=None, max_results=None):
"""Fetch job listings from the Adzuna API."""
role = role or config.TARGET_ROLE
location = location or config.LOCATION
max_results = max_results or config.MAX_RESULTS
url = f"http://api.adzuna.com/v1/api/jobs/{config.COUNTRY}/search/1"
params = {
"app_id": config.ADZUNA_APP_ID,
"app_key": config.ADZUNA_APP_KEY,
"what": role,
"where": location,
"results_per_page": max_results,
"content-type": "application/json",
}
response = requests.get(url, params=params)
response.raise_for_status()
data = response.json()
return process_results(data.get("results", []))
def process_results(raw_results):
"""Convert raw API results into clean job dictionaries."""
jobs = []
for item in raw_results:
job = {
"job_id": str(item.get("id", "")),
"title": item.get("title", "Unknown Title"),
"company": item.get("company", {}).get("display_name", "Unknown Company"),
"location": item.get("location", {}).get("display_name", "Unknown Location"),
"description": item.get("description", ""),
"apply_url": item.get("redirect_url", ""),
"salary_min": item.get("salary_min"),
"salary_max": item.get("salary_max"),
}
jobs.append(job)
return jobs
if __name__ == "__main__":
jobs = fetch_jobs()
print(f"Found {len(jobs)} jobs:")
for job in jobs:
print(f" - {job['title']} at {job['company']} ({job['location']})")
Before you run the fetcher, your Adzuna API credentials need to be set as environment variables so config.py can read them.
- Set your Adzuna credentials in the terminal by running these commands (replace the placeholder values with the app_id and app_key from your Adzuna developer dashboard):
export ADZUNA_APP_ID="your-app-id-here"
export ADZUNA_APP_KEY="your-app-key-here"
- Run the job fetcher by running this command:
python job_fetcher.py
You should see output listing real job titles, company names, and locations from India. Something like:
Found 10 jobs:
- Python Developer at TCS (Mumbai)
- Junior Software Engineer at Infosys (Mumbai, Maharashtra)
- Backend Developer at Wipro (Mumbai)
Why does Adzuna only show a snippet of the job description?
The Adzuna API returns a short snippet of each job description, not the full text. This is by design.
The snippet is enough for AI scoring. The redirect_url field (stored as apply_url in your code) takes the user to the full job posting when they click "View & Apply" on your dashboard.
Not seeing any jobs?
- If you see Warning: Adzuna keys not set, your environment variables are not loaded. Re-run the export commands above in the same terminal session.
- If you see a 401 Unauthorized error, double check that your app_id and app_key match exactly what your Adzuna developer dashboard shows. Extra spaces or missing characters will cause this.
- If you see Found 0 jobs, try a broader search term. Change TARGET_ROLE in config.py to something like "Python" or "Software Developer".
Still stuck? Help me debug my Adzuna API request. I'm running python job_fetcher.py but not getting any results.
Your tool can now pull live job listings from the internet. Next up, you'll send each of these jobs to Google Gemini AI and get a personalized match score based on your resume.
Score Jobs with Google Gemini AI
Your job fetcher is pulling live listings from the Adzuna API. You have real job data sitting in clean Python dictionaries ready to work with.
Now you need to figure out which of those jobs actually match your skills. In this step, you'll use Gemini AI to score each job against your CV on a scale of 0 to 100.
In this step, get ready to:
- Score each job against your CV using Google Gemini AI.
- Parse and validate AI responses with error handling.
- Rank all jobs by match score from best to worst.
Create the job scoring function
The scoring function needs to give Gemini enough context to make an accurate judgment. Your prompt will include the candidate's CV text, target role, experience level, and the full job description.
- Create a new file called job_scorer.py in your job-match-ai folder.
- Add the following code to set up the Gemini client and define the scoring function:
import json
import time
from google import genai
import config
# Create a Gemini API client (reads GEMINI_API_KEY env var automatically)
client = genai.Client()
def score_job(cv_text, job, role=None, experience=None):
"""Score a single job against the CV using Gemini AI."""
role = role or config.TARGET_ROLE
experience = experience or config.EXPERIENCE_LEVEL
# Build a detailed prompt with the candidate's CV and the job listing
prompt = f"""You are a job matching expert. Compare this candidate's resume against the job listing below.
CANDIDATE RESUME:
{cv_text[:3000]}
TARGET ROLE: {role}
EXPERIENCE LEVEL: {experience}
JOB LISTING:
Title: {job['title']}
Company: {job['company']}
Location: {job['location']}
Description: {job['description']}
Respond with ONLY valid JSON in this exact format (no markdown, no code fences):
{{"score": 75, "reason": "2-3 sentence explanation of why this score", "key_matches": ["skill1", "skill2", "skill3"]}}
Score guidelines:
- 80-100: Excellent match, most skills align
- 60-79: Good match, several skills align
- 40-59: Partial match, some relevant skills
- 0-39: Poor match, few skills align"""
try:
# Send the prompt to Gemini and get the response
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=prompt,
)
result_text = response.text.strip()
# Strip markdown code fences if Gemini added them
if result_text.startswith("```"):
result_text = result_text.split("\n", 1)[1]
result_text = result_text.rsplit("```", 1)[0]
return json.loads(result_text)
except (json.JSONDecodeError, Exception) as e:
print(f" Warning: Could not parse AI response for '{job['title']}': {e}")
return {"score": 0, "reason": "Could not analyze this job.", "key_matches": []}
What does this code do?
- genai.Client() creates a connection to the Gemini API. It automatically reads your GEMINI_API_KEY environment variable, so you do not need to pass the key in your code.
- The prompt gives Gemini four pieces of context: your CV (trimmed to 3000 characters to fit token limits), your target role, your experience level, and the job description.
- The prompt ends with strict formatting instructions telling Gemini to respond with only valid JSON containing score, reason, and key_matches.
- The try/except block catches cases where Gemini returns text that is not valid JSON. Instead of crashing, the function returns a default score of 0.
You will not see output from this function yet. The next substep explains the parsing logic, and the final substep adds the function that loops through all jobs.
Seeing an import error?
- Make sure you installed the google-genai library by running pip install google-genai in your activated virtual environment.
- Check that you are importing from google import genai, not import google.generativeai. The package name and import path are different.
- Why is my Gemini import failing?
Understand the response parsing
The score_job function handles an important challenge. LLMs do not always return perfectly formatted output, so your code needs to handle messy responses gracefully.
Why strip code fences from the response?
Even when your prompt says "no code fences," Gemini sometimes wraps its JSON response in triple backticks. The if result_text.startswith("```") block detects this and strips the fences before parsing. Without this step, json.loads() would fail on an otherwise valid response.
Why use try/except for JSON parsing?
LLMs are probabilistic. The same prompt can produce slightly different output each time. Occasionally, Gemini might return text that is not valid JSON. The try/except block catches json.JSONDecodeError and returns a default score of 0 instead of crashing the entire pipeline.
What is prompt engineering?
Prompt engineering is the practice of crafting input text to get useful, consistent output from a language model. In score_job, you use several techniques: providing structured context (CV, role, experience), requesting a specific output format (JSON with exact keys), and including scoring guidelines.
The more precise your prompt, the more reliable the output.
Score all jobs and sort by match
You have a function that scores one job. Now you need a function that loops through the entire list, scores each one, and returns the results ranked by best match.
- In job_scorer.py, add the score_all_jobs function below the score_job function:
def score_all_jobs(cv_text, jobs):
"""Score all jobs and return them sorted by score (highest first)."""
scored_jobs = []
for i, job in enumerate(jobs):
# Print progress so you can see scoring in real time
print(f" Scoring job {i + 1}/{len(jobs)}: {job['title']}...")
result = score_job(cv_text, job)
# Attach the AI's score, reason, and matched skills to the job dictionary
job["score"] = result.get("score", 0)
job["reason"] = result.get("reason", "")
job["key_matches"] = result.get("key_matches", [])
scored_jobs.append(job)
# Pause between API calls to respect Gemini's rate limits
time.sleep(1)
# Sort so the best matches appear first
scored_jobs.sort(key=lambda x: x["score"], reverse=True)
return scored_jobs
What does this code do?
- The function loops through every job in the list, calling score_job for each one.
- It prints progress as it goes (Scoring job 3/10...) so you can see it working in the terminal.
- time.sleep(1) adds a one-second pause between API calls. This prevents you from hitting Gemini's free tier rate limits.
- After scoring all jobs, it sorts them by score in descending order so the best matches appear first.
- Save job_scorer.py.
You will not see a change yet. The main pipeline script wires this module together with the CV parser and job fetcher in a later step.
Worried about rate limits?
- The gemini-2.0-flash model has a generous free tier. With time.sleep(1) between calls and only 10 jobs to score, you will stay well within the limits.
- If you do see an HTTP 429 error, wait a minute and try again. The rate limit resets quickly.
- How do I handle Gemini API rate limits in my scoring script?
Awesome, I've got everything!
Great. Double check you have saved job_scorer.py with both functions.
I'd like to double check the full code
Here is the complete job_scorer.py file. Make sure yours matches:
import json
import time
from google import genai
import config
client = genai.Client()
def score_job(cv_text, job, role=None, experience=None):
"""Score a single job against the CV using Gemini AI."""
role = role or config.TARGET_ROLE
experience = experience or config.EXPERIENCE_LEVEL
prompt = f"""You are a job matching expert. Compare this candidate's resume against the job listing below.
CANDIDATE RESUME:
{cv_text[:3000]}
TARGET ROLE: {role}
EXPERIENCE LEVEL: {experience}
JOB LISTING:
Title: {job['title']}
Company: {job['company']}
Location: {job['location']}
Description: {job['description']}
Respond with ONLY valid JSON in this exact format (no markdown, no code fences):
{{"score": 75, "reason": "2-3 sentence explanation of why this score", "key_matches": ["skill1", "skill2", "skill3"]}}
Score guidelines:
- 80-100: Excellent match, most skills align
- 60-79: Good match, several skills align
- 40-59: Partial match, some relevant skills
- 0-39: Poor match, few skills align"""
try:
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=prompt,
)
result_text = response.text.strip()
if result_text.startswith("```"):
result_text = result_text.split("\n", 1)[1]
result_text = result_text.rsplit("```", 1)[0]
return json.loads(result_text)
except (json.JSONDecodeError, Exception) as e:
print(f" Warning: Could not parse AI response for '{job['title']}': {e}")
return {"score": 0, "reason": "Could not analyze this job.", "key_matches": []}
def score_all_jobs(cv_text, jobs):
"""Score all jobs and return them sorted by score (highest first)."""
scored_jobs = []
for i, job in enumerate(jobs):
print(f" Scoring job {i + 1}/{len(jobs)}: {job['title']}...")
result = score_job(cv_text, job)
job["score"] = result.get("score", 0)
job["reason"] = result.get("reason", "")
job["key_matches"] = result.get("key_matches", [])
scored_jobs.append(job)
time.sleep(1)
scored_jobs.sort(key=lambda x: x["score"], reverse=True)
return scored_jobs
Your AI scoring engine is ready. Next up, you'll build the database module that stores these scored jobs so you never lose your results and never see duplicates.
Store Results in SQLite Database
Your AI scoring engine is working. Each job now has a match score, a reason, and a list of matching skills.
Scores sitting in memory disappear the moment your script ends. In this step, you will build a SQLite database module that saves every job permanently. This also prevents duplicate entries when you run the script again tomorrow.
In this step, get ready to:
- Create the database.py module with the jobs table schema.
- Add functions to insert, retrieve, and update job records.
- Test the database by running database.py standalone.
Create database.py and initialize the jobs table
Python includes the sqlite3 module in its standard library. No extra installation is needed.
- Create a new file called database.py inside your job-match-ai folder.
- Add the imports, database name constant, and the init_db() function by copying the following code:
import sqlite3
from datetime import date
DB_NAME = "jobs.db"
def init_db():
"""Create the jobs table if it does not exist."""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
company TEXT,
location TEXT,
description TEXT,
apply_url TEXT,
salary_min REAL,
salary_max REAL,
score INTEGER DEFAULT 0,
reason TEXT,
key_matches TEXT,
date_found TEXT,
status TEXT DEFAULT 'new'
)
""")
conn.commit()
conn.close()
What does this code do?
- sqlite3.connect(DB_NAME) opens (or creates) a database file called jobs.db in your project folder.
- CREATE TABLE IF NOT EXISTS defines the table structure only if it does not already exist. This makes the function safe to call multiple times.
- job_id TEXT PRIMARY KEY sets job_id as the primary key. No two rows can share the same job_id. This is what prevents duplicate jobs later.
- conn.commit() saves the changes to disk. conn.close() releases the database connection.
You will not see any output yet. The next chunk adds the functions that read and write job records.
Add CRUD functions for job records
CRUD stands for Create, Read, Update, Delete. These are the four basic operations every database needs. Your module will handle inserting new jobs, retrieving all stored jobs, and updating a job's status.
- In database.py, add the following two insert functions below the init_db() function:
def insert_job(job):
"""Insert a job into the database. Skips if job_id already exists."""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
try:
cursor.execute(
"""INSERT OR IGNORE INTO jobs
(job_id, title, company, location, description, apply_url,
salary_min, salary_max, score, reason, key_matches, date_found, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
job["job_id"],
job["title"],
job["company"],
job["location"],
job["description"],
job["apply_url"],
job.get("salary_min"),
job.get("salary_max"),
job.get("score", 0),
job.get("reason", ""),
",".join(job.get("key_matches", [])),
str(date.today()),
"new",
),
)
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def insert_all_jobs(jobs):
"""Insert multiple jobs and return count of newly added ones."""
new_count = 0
for job in jobs:
if insert_job(job):
new_count += 1
return new_count
What does this code do?
- INSERT OR IGNORE tries to add a new row. If a row with the same job_id already exists, it silently skips instead of crashing. This is how you prevent duplicates across runs.
- The ? placeholders are parameterized queries. They protect against SQL injection by keeping user data separate from the SQL command.
- cursor.rowcount > 0 returns True if a row was actually inserted and False if it was a duplicate that got skipped.
- insert_all_jobs() loops through every job and counts how many were genuinely new. This count tells the user how many fresh results they got.
- The finally block ensures the database connection closes even if an error occurs during the insert.
You will not see any output yet. The next chunk adds the read and update functions.
- Add the following two functions below insert_all_jobs() in database.py:
def get_all_jobs():
"""Return all jobs sorted by score descending."""
conn = sqlite3.connect(DB_NAME)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM jobs ORDER BY score DESC")
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def update_status(job_id, status):
"""Update the status of a job (new, applied, skipped)."""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("UPDATE jobs SET status = ? WHERE job_id = ?", (status, job_id))
conn.commit()
conn.close()
What does this code do?
- conn.row_factory = sqlite3.Row tells SQLite to return rows as dictionary-like objects instead of plain tuples. This lets you access columns by name (like row["title"]) instead of by index number.
- ORDER BY score DESC sorts the results so the best-matching jobs appear first.
- update_status() uses parameterized queries (the ? placeholders) to safely update a single job's status to applied or skipped.
You will not see any output yet. The next chunk adds a test block so you can run the module standalone.
Test the database module
Every module in this project includes a test block that runs when you execute the file directly. This block uses Python's if __name__ == "__main__" pattern, which only triggers when the file is run as a script, not when it is imported by another file.
- Add the following test block at the very bottom of database.py:
if __name__ == "__main__":
init_db()
print("Database initialized. Table 'jobs' is ready.")
What does this code do?
When you run python database.py directly, this block calls init_db() to create the jobs.db file and the jobs table inside it. When main.py imports this module later, this block is skipped.
- Save database.py.
- Run the module by entering this command in your terminal:
python database.py
You should see Database initialized. Table 'jobs' is ready. printed in your terminal.
- Confirm the database file was created by running:
ls
You should see jobs.db listed alongside your other project files.
Don't see jobs.db?
- Make sure you saved database.py before running it.
- Check that your terminal is in the job-match-ai folder. If you are in a different directory, the file gets created there instead.
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- Why isn't my database.py creating the jobs.db file?
Awesome, I've got everything!
Double check you have saved database.py and that jobs.db appears in your project folder.
I'd like to double check the full code
import sqlite3
from datetime import date
DB_NAME = "jobs.db"
def init_db():
"""Create the jobs table if it does not exist."""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
company TEXT,
location TEXT,
description TEXT,
apply_url TEXT,
salary_min REAL,
salary_max REAL,
score INTEGER DEFAULT 0,
reason TEXT,
key_matches TEXT,
date_found TEXT,
status TEXT DEFAULT 'new'
)
""")
conn.commit()
conn.close()
def insert_job(job):
"""Insert a job into the database. Skips if job_id already exists."""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
try:
cursor.execute(
"""INSERT OR IGNORE INTO jobs
(job_id, title, company, location, description, apply_url,
salary_min, salary_max, score, reason, key_matches, date_found, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
job["job_id"],
job["title"],
job["company"],
job["location"],
job["description"],
job["apply_url"],
job.get("salary_min"),
job.get("salary_max"),
job.get("score", 0),
job.get("reason", ""),
",".join(job.get("key_matches", [])),
str(date.today()),
"new",
),
)
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def insert_all_jobs(jobs):
"""Insert multiple jobs and return count of newly added ones."""
new_count = 0
for job in jobs:
if insert_job(job):
new_count += 1
return new_count
def get_all_jobs():
"""Return all jobs sorted by score descending."""
conn = sqlite3.connect(DB_NAME)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM jobs ORDER BY score DESC")
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def update_status(job_id, status):
"""Update the status of a job (new, applied, skipped)."""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("UPDATE jobs SET status = ? WHERE job_id = ?", (status, job_id))
conn.commit()
conn.close()
if __name__ == "__main__":
init_db()
print("Database initialized. Table 'jobs' is ready.")
Your jobs now have a permanent home. Every time you run the script, new jobs get added and duplicates get skipped automatically. Next up, you will build the interactive HTML/CSS/JS dashboard that reads from this database and displays your top matches in the browser.
Build the Interactive HTML/CSS/JS Dashboard
Your jobs are scored, stored, and deduplicated in a SQLite database. Now you need a way to actually look at them.
In this step, you will build a real web page from scratch using HTML for structure, CSS for styling, and JavaScript for interactivity. Python generates the page, but the frontend code inside it is all hand-coded by you.
In this step, get ready to:
- Create a Python module that reads job data and generates a complete HTML page with embedded CSS.
- Build color-coded job cards showing scores, companies, AI reasoning, and apply links.
- Add JavaScript controls that let you filter jobs by score and status without reloading the page.
Create report.py and set up the score color helper
The dashboard uses color to communicate match quality at a glance. Green means a strong match, yellow means partial, and red means poor.
- Create a new file called report.py in your job-match-ai folder.
- Add the imports and the score color helper function by pasting this code:
import webbrowser
import os
import database
import config
def get_score_color(score):
"""Return CSS color based on score."""
if score >= 70:
return "#22c55e" # Green for strong matches
elif score >= 40:
return "#eab308" # Yellow for partial matches
else:
return "#ef4444" # Red for poor matches
What does this code do?
- webbrowser and os are standard library modules for opening the browser and resolving file paths.
- database and config are the modules you built in earlier steps.
- get_score_color() maps a numeric score to a hex color code. The card generation code calls this for every job.
You will not see a change yet. The next chunk uses this function to color-code each job card.
Generate an HTML card for each job listing
Each job from the database becomes an HTML card with a score badge, title, company, salary, the AI's reasoning, matched skills as tags, and an apply link.
What are data attributes?
HTML data-* attributes let you store custom values directly on an element. In this project, each card stores its score and status as data-score and data-status. JavaScript reads these values later to filter cards without ever touching the database.
- In report.py, below the get_score_color() function, start the generate_dashboard() function by pasting this code:
def generate_dashboard():
"""Generate an interactive HTML/CSS/JS dashboard from the database."""
# Pull all scored jobs from the database
jobs = database.get_all_jobs()
# Build an HTML card for each job
job_cards = ""
for job in jobs:
# Format salary with the Rupee symbol if available
salary_text = ""
if job.get("salary_min") and job.get("salary_max"):
salary_text = f"₹{int(job['salary_min']):,} - ₹{int(job['salary_max']):,}"
elif job.get("salary_min"):
salary_text = f"From ₹{int(job['salary_min']):,}"
# Build skill tags from the comma-separated key_matches string
matches_html = ""
if job.get("key_matches"):
skills = job["key_matches"].split(",") if isinstance(job["key_matches"], str) else job["key_matches"]
for skill in skills:
skill = skill.strip()
if skill:
matches_html += f'<span class="tag">{skill}</span>'
# Dim applied jobs, color-code the score badge
applied_class = " applied" if job.get("status") == "applied" else ""
score_color = get_score_color(job.get("score", 0))
# Store score and status as data attributes for JS filtering
job_cards += f"""
<div class="card{applied_class}" data-score="{job.get('score', 0)}" data-status="{job.get('status', 'new')}">
<div class="card-header">
<span class="score-badge" style="background:{score_color}">{job.get('score', 0)}</span>
<div>
<h3>{job['title']}</h3>
<p class="company">{job['company']} · {job['location']}</p>
</div>
</div>
{f'<p class="salary">{salary_text}</p>' if salary_text else ''}
<p class="reason">{job.get('reason', '')}</p>
<div class="tags">{matches_html}</div>
<div class="card-footer">
<a href="{job['apply_url']}" target="_blank" class="btn-apply">View & Apply</a>
<span class="date">Found: {job.get('date_found', '')}</span>
</div>
</div>
"""
What does this code do?
- database.get_all_jobs() pulls every job from the SQLite database, already sorted by score.
- ₹ is the HTML entity for the Indian Rupee symbol.
- The loop builds one HTML card per job. Each card includes the score badge (colored by get_score_color()), the job title, company name, AI reasoning, matched skills as tags, and a link to the original posting.
- The applied CSS class dims cards you have already applied to, so they fade into the background.
You will not see a change yet. This is the first half of the generate_dashboard() function. The next chunk completes it with the full HTML template.
Add the HTML template with CSS styling and JavaScript filtering
This chunk completes the generate_dashboard() function. It assembles the full HTML page with CSS for layout and styling, injects the job cards you just built, and adds JavaScript for interactive filtering.
What is the DOM?
The DOM (Document Object Model) is a tree-like representation of an HTML page that the browser creates. JavaScript can read and change this tree to update what the user sees without reloading the page. In this project, the filterJobs() function modifies the DOM by toggling each card's visibility.
- In report.py, directly below the job card loop (still inside the generate_dashboard() function), paste this code:
# Assemble the full HTML page with CSS styling and JS filtering
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Job Match Dashboard</title>
<style>
* {{{{ margin: 0; padding: 0; box-sizing: border-box; }}}}
body {{{{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; padding: 20px; }}}}
.header {{{{ text-align: center; margin-bottom: 30px; }}}}
.header h1 {{{{ font-size: 1.8rem; margin-bottom: 5px; }}}}
.header p {{{{ color: #64748b; }}}}
.filters {{{{ display: flex; gap: 15px; justify-content: center; margin-bottom: 25px; flex-wrap: wrap; align-items: center; }}}}
.filters label {{{{ font-weight: 600; }}}}
.filters select, .filters input {{{{ padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; }}}}
.grid {{{{ display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }}}}
.card {{{{ background: white; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: transform 0.2s; }}}}
.card:hover {{{{ transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }}}}
.card.applied {{{{ opacity: 0.6; }}}}
.card-header {{{{ display: flex; gap: 12px; align-items: flex-start; margin-bottom: 10px; }}}}
.score-badge {{{{ color: white; font-weight: 700; font-size: 1.1rem; padding: 6px 12px; border-radius: 8px; min-width: 45px; text-align: center; }}}}
.card-header h3 {{{{ font-size: 1rem; margin-bottom: 3px; }}}}
.company {{{{ color: #64748b; font-size: 0.85rem; }}}}
.salary {{{{ color: #059669; font-weight: 600; font-size: 0.9rem; margin-bottom: 8px; }}}}
.reason {{{{ font-size: 0.85rem; color: #475569; margin-bottom: 10px; line-height: 1.4; }}}}
.tags {{{{ display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }}}}
.tag {{{{ background: #e0f2fe; color: #0369a1; padding: 3px 8px; border-radius: 4px; font-size: 0.75rem; }}}}
.card-footer {{{{ display: flex; justify-content: space-between; align-items: center; }}}}
.btn-apply {{{{ background: #2563eb; color: white; padding: 8px 16px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; font-weight: 600; }}}}
.btn-apply:hover {{{{ background: #1d4ed8; }}}}
.date {{{{ color: #94a3b8; font-size: 0.75rem; }}}}
</style>
</head>
<body>
<div class="header">
<h1>Job Match Dashboard</h1>
<p>Target: {{config.TARGET_ROLE}} | {{len(jobs)}} jobs tracked</p>
</div>
<div class="filters">
<label>Min Score:</label>
<input type="range" id="scoreFilter" min="0" max="100" value="0" oninput="filterJobs()">
<span id="scoreValue">0</span>
<label>Status:</label>
<select id="statusFilter" onchange="filterJobs()">
<option value="all">All</option>
<option value="new">New</option>
<option value="applied">Applied</option>
</select>
</div>
<div class="grid" id="jobGrid">
{{job_cards}}
</div>
<script>
function filterJobs() {{{{
var minScore = parseInt(document.getElementById('scoreFilter').value);
document.getElementById('scoreValue').textContent = minScore;
var statusFilter = document.getElementById('statusFilter').value;
var cards = document.querySelectorAll('.card');
cards.forEach(function(card) {{{{
var score = parseInt(card.getAttribute('data-score'));
var status = card.getAttribute('data-status');
var showScore = score >= minScore;
var showStatus = statusFilter === 'all' || status === statusFilter;
card.style.display = (showScore && showStatus) ? 'block' : 'none';
}}}});
}}}}
</script>
</body>
</html>"""
# Write the HTML file and open it in the default browser
output_path = "dashboard.html"
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"Dashboard saved to {output_path}")
webbrowser.open("file://" + os.path.abspath(output_path))
if __name__ == "__main__":
generate_dashboard()
What do the CSS, HTML, and JavaScript sections do?
This is a lot of code, but it breaks into three clear sections.
- CSS (the <style> block): CSS Grid handles the responsive card layout with repeat(auto-fill, minmax(350px, 1fr)), which automatically wraps cards to fit the screen width. Flexbox is used inside each card for the header and footer layout. Hover effects and transitions give cards a subtle lift on mouseover.
- HTML (the <body> section): The header shows your target role and total job count. The filters section contains a range slider for minimum score and a dropdown for status. The {job_cards} placeholder injects all the cards you built in the previous chunk.
- JavaScript (the <script> block): filterJobs() uses document.querySelectorAll('.card') to find every card, reads each card's data-score and data-status attributes, and toggles visibility by setting display: none or display: block.
Why double braces in the f-string?
Python f-strings use {} for variable substitution. CSS and JavaScript also use curly braces for their own syntax. To print a literal { in an f-string, you write {{. That is why every CSS rule and JavaScript function body uses doubled braces.
- Save report.py.
- Test the dashboard by running this command:
python report.py
Your default browser should open with the Job Match Dashboard. You should see your job cards arranged in a responsive grid, each with a colored score badge, company name, AI reasoning, and a blue "View & Apply" button.
- Drag the Min Score slider to the right. Cards with scores below the threshold should disappear.
- Change the Status dropdown to New. Only cards with status "new" should remain visible.
Dashboard not opening or showing no cards?
- If the browser does not open automatically, look for the dashboard.html file in your project folder and double-click it to open manually.
- If the page opens but shows no cards, your database is empty. Go back to Step 5 and run python database.py to make sure the test record was inserted.
- If you see a Python error about database or config not found, make sure you are running the command from inside the job-match-ai folder where all your .py files live.
- If the CSS looks broken (no grid, no colors), check that the double braces {{ and }} are correct in your f-string. Missing a brace turns CSS rules into Python substitution errors.
- Still stuck? Help me debug why my dashboard.html is not displaying job cards correctly.
Awesome, I've got everything!
Great. Double check you have saved report.py.
I'd like to double check the full code
Here is the complete report.py file. Make sure yours matches exactly.
import webbrowser
import os
import database
import config
def get_score_color(score):
"""Return CSS color based on score."""
if score >= 70:
return "#22c55e"
elif score >= 40:
return "#eab308"
else:
return "#ef4444"
def generate_dashboard():
"""Generate an interactive HTML/CSS/JS dashboard from the database."""
jobs = database.get_all_jobs()
job_cards = ""
for job in jobs:
salary_text = ""
if job.get("salary_min") and job.get("salary_max"):
salary_text = f"₹{int(job['salary_min']):,} - ₹{int(job['salary_max']):,}"
elif job.get("salary_min"):
salary_text = f"From ₹{int(job['salary_min']):,}"
matches_html = ""
if job.get("key_matches"):
skills = job["key_matches"].split(",") if isinstance(job["key_matches"], str) else job["key_matches"]
for skill in skills:
skill = skill.strip()
if skill:
matches_html += f'<span class="tag">{skill}</span>'
applied_class = " applied" if job.get("status") == "applied" else ""
score_color = get_score_color(job.get("score", 0))
job_cards += f"""
<div class="card{applied_class}" data-score="{job.get('score', 0)}" data-status="{job.get('status', 'new')}">
<div class="card-header">
<span class="score-badge" style="background:{score_color}">{job.get('score', 0)}</span>
<div>
<h3>{job['title']}</h3>
<p class="company">{job['company']} · {job['location']}</p>
</div>
</div>
{f'<p class="salary">{salary_text}</p>' if salary_text else ''}
<p class="reason">{job.get('reason', '')}</p>
<div class="tags">{matches_html}</div>
<div class="card-footer">
<a href="{job['apply_url']}" target="_blank" class="btn-apply">View & Apply</a>
<span class="date">Found: {job.get('date_found', '')}</span>
</div>
</div>
"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Job Match Dashboard</title>
<style>
* {{{{ margin: 0; padding: 0; box-sizing: border-box; }}}}
body {{{{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; padding: 20px; }}}}
.header {{{{ text-align: center; margin-bottom: 30px; }}}}
.header h1 {{{{ font-size: 1.8rem; margin-bottom: 5px; }}}}
.header p {{{{ color: #64748b; }}}}
.filters {{{{ display: flex; gap: 15px; justify-content: center; margin-bottom: 25px; flex-wrap: wrap; align-items: center; }}}}
.filters label {{{{ font-weight: 600; }}}}
.filters select, .filters input {{{{ padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; }}}}
.grid {{{{ display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }}}}
.card {{{{ background: white; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: transform 0.2s; }}}}
.card:hover {{{{ transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }}}}
.card.applied {{{{ opacity: 0.6; }}}}
.card-header {{{{ display: flex; gap: 12px; align-items: flex-start; margin-bottom: 10px; }}}}
.score-badge {{{{ color: white; font-weight: 700; font-size: 1.1rem; padding: 6px 12px; border-radius: 8px; min-width: 45px; text-align: center; }}}}
.card-header h3 {{{{ font-size: 1rem; margin-bottom: 3px; }}}}
.company {{{{ color: #64748b; font-size: 0.85rem; }}}}
.salary {{{{ color: #059669; font-weight: 600; font-size: 0.9rem; margin-bottom: 8px; }}}}
.reason {{{{ font-size: 0.85rem; color: #475569; margin-bottom: 10px; line-height: 1.4; }}}}
.tags {{{{ display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }}}}
.tag {{{{ background: #e0f2fe; color: #0369a1; padding: 3px 8px; border-radius: 4px; font-size: 0.75rem; }}}}
.card-footer {{{{ display: flex; justify-content: space-between; align-items: center; }}}}
.btn-apply {{{{ background: #2563eb; color: white; padding: 8px 16px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; font-weight: 600; }}}}
.btn-apply:hover {{{{ background: #1d4ed8; }}}}
.date {{{{ color: #94a3b8; font-size: 0.75rem; }}}}
</style>
</head>
<body>
<div class="header">
<h1>Job Match Dashboard</h1>
<p>Target: {{config.TARGET_ROLE}} | {{len(jobs)}} jobs tracked</p>
</div>
<div class="filters">
<label>Min Score:</label>
<input type="range" id="scoreFilter" min="0" max="100" value="0" oninput="filterJobs()">
<span id="scoreValue">0</span>
<label>Status:</label>
<select id="statusFilter" onchange="filterJobs()">
<option value="all">All</option>
<option value="new">New</option>
<option value="applied">Applied</option>
</select>
</div>
<div class="grid" id="jobGrid">
{{job_cards}}
</div>
<script>
function filterJobs() {{{{
var minScore = parseInt(document.getElementById('scoreFilter').value);
document.getElementById('scoreValue').textContent = minScore;
var statusFilter = document.getElementById('statusFilter').value;
var cards = document.querySelectorAll('.card');
cards.forEach(function(card) {{{{
var score = parseInt(card.getAttribute('data-score'));
var status = card.getAttribute('data-status');
var showScore = score >= minScore;
var showStatus = statusFilter === 'all' || status === statusFilter;
card.style.display = (showScore && showStatus) ? 'block' : 'none';
}}}});
}}}}
</script>
</body>
</html>"""
output_path = "dashboard.html"
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"Dashboard saved to {output_path}")
webbrowser.open("file://" + os.path.abspath(output_path))
if __name__ == "__main__":
generate_dashboard()
Your dashboard is live. Next up, you will wire every module together into a single pipeline that runs the full flow from CV parsing to dashboard generation with one command.
Wire Everything Together in main.py
Your modules are built and tested individually. You have a CV parser, a job fetcher, an AI scorer, a database layer, and a dashboard generator. Now it is time to connect them into a single pipeline that runs end to end.
In this step, you will create main.py, the orchestrator script. One command will extract your resume, fetch live jobs, score them with AI, store results in the database, and generate your dashboard.
In this step, get ready to:
- Create main.py with command-line argument support using argparse.
- Wire all six modules into a pipeline with error handling at every stage.
- Run the full pipeline and verify the generated dashboard in your browser.
Create the main orchestrator script
The main.py file imports every module you have built so far. It also uses Python's built-in argparse module to let you customize the job search directly from the terminal.
- Create a new file called main.py in your job-match-ai folder and add the following imports and argument setup:
import argparse
import config
from cv_parser import extract_cv_text
from job_fetcher import fetch_jobs
from job_scorer import score_all_jobs
from database import init_db, insert_all_jobs
from report import generate_dashboard
def main():
# Set up command-line arguments so users can customize the search
parser = argparse.ArgumentParser(description="AI Job Match Assistant")
parser.add_argument("--cv", default=config.CV_PATH, help="Path to your CV PDF")
parser.add_argument("--role", default=config.TARGET_ROLE, help="Target job role")
parser.add_argument("--location", default=config.LOCATION, help="Job location")
parser.add_argument("--dry-run", action="store_true", help="Score jobs without saving to database")
args = parser.parse_args()
# Print a banner so the user knows the tool is running
print("=" * 50)
print(" AI Job Match Assistant")
print("=" * 50)
What does this code do?
- The imports bring in every module you built in the previous steps. Each from X import Y statement pulls in a specific function you need from that module.
- argparse.ArgumentParser() creates a command-line interface. Each add_argument() call defines an optional flag like --role or --cv. If the user does not pass a flag, it falls back to the default from config.py.
- --dry-run uses action="store_true", which means it is a boolean flag. If passed, args.dry_run is True. If omitted, it is False.
What is argparse?
argparse is a built-in Python module for parsing command-line arguments. It lets users pass options like --role "Data Analyst" when running your script. It also automatically generates a help message when you run python main.py --help. This is the standard way to make Python scripts configurable from the terminal.
You will not see a change yet. The next chunk adds the pipeline logic that calls each module.
Add the pipeline logic with error handling
Each stage of the pipeline needs its own try/except block so the script tells you exactly what went wrong instead of crashing with an unhelpful traceback.
- In main.py, add the following pipeline body directly below the banner print statements, still inside the main() function. Then add the if __name__ guard at the very bottom of the file, outside the function:
# Step 1: Extract text from the CV PDF
print(f"\n1. Extracting text from CV: {args.cv}")
try:
cv_text = extract_cv_text(args.cv)
print(f" Extracted {len(cv_text)} characters.")
except FileNotFoundError:
print(f" Error: CV file '{args.cv}' not found. Place your resume PDF in the project folder.")
return
except Exception as e:
print(f" Error reading CV: {e}")
return
# Step 2: Fetch job listings from Adzuna
print(f"\n2. Fetching jobs for '{args.role}' in '{args.location}'...")
try:
jobs = fetch_jobs(role=args.role, location=args.location)
print(f" Found {len(jobs)} jobs.")
except Exception as e:
print(f" Error fetching jobs: {e}")
print(" Check your ADZUNA_APP_ID and ADZUNA_APP_KEY environment variables.")
return
if not jobs:
print(" No jobs found. Try a different role or location.")
return
# Step 3: Score each job with Gemini AI
print(f"\n3. Scoring jobs with Gemini AI...")
try:
scored_jobs = score_all_jobs(cv_text, jobs)
except Exception as e:
print(f" Error scoring jobs: {e}")
print(" Check your GEMINI_API_KEY environment variable.")
return
# If dry-run, print results without saving
if args.dry_run:
print("\n4. Dry run mode. Skipping database save.")
for job in scored_jobs:
print(f" [{job['score']}] {job['title']} at {job['company']}")
return
# Step 4: Save scored jobs to the database
print(f"\n4. Saving to database...")
init_db()
new_count = insert_all_jobs(scored_jobs)
print(f" {new_count} new jobs added. {len(scored_jobs) - new_count} duplicates skipped.")
# Step 5: Generate the HTML dashboard
print(f"\n5. Generating dashboard...")
generate_dashboard()
print(f"\nDone! Open dashboard.html in your browser to see your matches.")
if __name__ == "__main__":
main()
What does this code do?
- The pipeline runs five stages in order: CV extraction, job fetching, AI scoring, database storage, and dashboard generation.
- Each stage is wrapped in its own try/except block. If one stage fails, the script prints a helpful error message (like "Check your ADZUNA_APP_ID") and stops with return instead of crashing.
- The --dry-run check skips the database save and dashboard generation. It prints the scores directly to the terminal instead. This is useful for testing without changing your database.
- insert_all_jobs() returns the count of new jobs added. Jobs that already exist in the database are silently skipped thanks to the INSERT OR IGNORE logic you built in database.py.
What does if __name__ == '__main__' mean?
This line checks whether the file is being run directly (as a script) or being imported as a module. Code inside this block only runs when you execute the file with python main.py. It does not run when another file imports from this module.
You have seen this pattern in every module you have built so far. In those modules, the guard ran test code. In main.py, it calls the main() function to kick off the full pipeline.
- Save main.py.
Seeing a red underline on an import?
- Make sure all your module files (config.py, cv_parser.py, job_fetcher.py, job_scorer.py, database.py, report.py) are in the same folder as main.py.
- Check that your virtual environment is active. You should see (venv) at the start of your terminal prompt.
- Check that function names in your imports match the function names in each module exactly.
Why is my import failing in main.py?
Awesome, I've got everything!
Double check you have saved main.py.
I'd like to double check the full code
import argparse
import config
from cv_parser import extract_cv_text
from job_fetcher import fetch_jobs
from job_scorer import score_all_jobs
from database import init_db, insert_all_jobs
from report import generate_dashboard
def main():
parser = argparse.ArgumentParser(description="AI Job Match Assistant")
parser.add_argument("--cv", default=config.CV_PATH, help="Path to your CV PDF")
parser.add_argument("--role", default=config.TARGET_ROLE, help="Target job role")
parser.add_argument("--location", default=config.LOCATION, help="Job location")
parser.add_argument("--dry-run", action="store_true", help="Score jobs without saving to database")
args = parser.parse_args()
print("=" * 50)
print(" AI Job Match Assistant")
print("=" * 50)
print(f"\n1. Extracting text from CV: {args.cv}")
try:
cv_text = extract_cv_text(args.cv)
print(f" Extracted {len(cv_text)} characters.")
except FileNotFoundError:
print(f" Error: CV file '{args.cv}' not found. Place your resume PDF in the project folder.")
return
except Exception as e:
print(f" Error reading CV: {e}")
return
print(f"\n2. Fetching jobs for '{args.role}' in '{args.location}'...")
try:
jobs = fetch_jobs(role=args.role, location=args.location)
print(f" Found {len(jobs)} jobs.")
except Exception as e:
print(f" Error fetching jobs: {e}")
print(" Check your ADZUNA_APP_ID and ADZUNA_APP_KEY environment variables.")
return
if not jobs:
print(" No jobs found. Try a different role or location.")
return
print(f"\n3. Scoring jobs with Gemini AI...")
try:
scored_jobs = score_all_jobs(cv_text, jobs)
except Exception as e:
print(f" Error scoring jobs: {e}")
print(" Check your GEMINI_API_KEY environment variable.")
return
if args.dry_run:
print("\n4. Dry run mode. Skipping database save.")
for job in scored_jobs:
print(f" [{job['score']}] {job['title']} at {job['company']}")
return
print(f"\n4. Saving to database...")
init_db()
new_count = insert_all_jobs(scored_jobs)
print(f" {new_count} new jobs added. {len(scored_jobs) - new_count} duplicates skipped.")
print(f"\n5. Generating dashboard...")
generate_dashboard()
print(f"\nDone! Open dashboard.html in your browser to see your matches.")
if __name__ == "__main__":
main()
Run the complete pipeline and verify the dashboard
Before running the pipeline, you need to set your API keys as environment variables so config.py can read them. These stay active for the current terminal session.
- Set your three API keys as environment variables by running these commands (replace each placeholder with your actual key):
export GEMINI_API_KEY="your-gemini-api-key-here"
export ADZUNA_APP_ID="your-adzuna-app-id-here"
export ADZUNA_APP_KEY="your-adzuna-app-key-here"
Where do I find these keys?
- Your Gemini API key is on https://aistudio.google.com/apikey where you created it in Step 1.
- Your Adzuna app_id and app_key are on your Adzuna developer dashboard at https://developer.adzuna.com.
- Make sure your resume PDF is in the job-match-ai folder. If your file is named something other than resume.pdf, you will pass the correct filename with the --cv flag in the next command.
- Run the full pipeline by running this command:
python main.py --cv resume.pdf --role "Junior Python Developer" --location "Mumbai"
Pro tip: customize your search
Change the --role and --location values to match your actual job search. For example, --role "Data Analyst" --location "Bangalore". If your resume file has a different name, pass it with --cv your-file.pdf.
The script takes about 15-30 seconds to run because it scores each job one at a time with a 1-second delay between API calls. You should see numbered progress messages in your terminal as each stage completes.
What should I see?
Your terminal output should look something like this:
==================================================
AI Job Match Assistant
==================================================
1. Extracting text from CV: resume.pdf
Extracted 2847 characters.
2. Fetching jobs for 'Junior Python Developer' in 'Mumbai'...
Found 10 jobs.
3. Scoring jobs with Gemini AI...
Scoring job 1/10: Python Developer...
Scoring job 2/10: Backend Engineer...
...
4. Saving to database...
10 new jobs added. 0 duplicates skipped.
5. Generating dashboard...
Dashboard saved to dashboard.html
Done! Open dashboard.html in your browser to see your matches.
After the script finishes, your browser should automatically open dashboard.html with your scored job cards.
Getting an error?
- "CV file not found" means your resume PDF is not in the job-match-ai folder, or the filename you passed to --cv does not match exactly (including the extension).
- "Check your ADZUNA_APP_ID" means your Adzuna environment variables are not set. Re-run the export commands above. On Windows, use set instead of export.
- "Check your GEMINI_API_KEY" means your Gemini key is missing or invalid. Double check the key on https://aistudio.google.com/apikey and re-export it.
- "No jobs found" means the Adzuna API returned zero results for your search. Try a broader role like "Python Developer" or a larger city like "Bangalore".
My pipeline is failing. Help me debug the error.
- Verify the dashboard shows your job cards with color-coded score badges, company names, and "View & Apply" links.
Now test the deduplication. The SQLite database uses the job_id primary key to prevent the same job from being stored twice.
- Run the same command again:
python main.py --cv resume.pdf --role "Junior Python Developer" --location "Mumbai"
- Check the output at step 4. You should see 0 new jobs added. 10 duplicates skipped. (or similar numbers). This confirms your database is preventing duplicates.
Why does deduplication matter?
You can run this script daily to get fresh job recommendations. Each run only adds jobs you have not seen before. Over time, your dashboard builds up a personalized library of scored matches without wasting your Gemini API quota re-scoring the same listings.
Secret mission
Add a "Mark as Applied" Button with a Micro Web Server
You built a dashboard that shows your job matches. But right now, there is no way to track which jobs you have applied to from the dashboard itself. In this secret mission, you will add a "Mark as Applied" button to each job card. Clicking it sends a request to a tiny Python HTTP server that updates the database in real time. This is client-server architecture in action.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with free API tiers, so there are no ongoing costs.
Resources you used:
- job-match-ai project folder (Python files, virtual environment, database).
- jobs.db SQLite database file.
- dashboard.html generated web page.
- venv virtual environment folder.
- Google Gemini API key (free tier, no billing).
- Adzuna API account (free tier, no billing).
- Environment variables (GEMINI_API_KEY, ADZUNA_APP_ID, ADZUNA_APP_KEY) set in your terminal session.
Keep everything running
No action needed. Choose this if you are still actively building or plan to run the tool daily to get fresh job matches.
- Your project folder, database, and dashboard stay as they are.
- Your API keys remain active and free to use.
- You can run python main.py any time to fetch new jobs without duplicating old ones.
Pause - I'll come back to this later
Deactivate your virtual environment to free up your terminal, but keep all files for later.
- Run deactivate in your terminal to exit the virtual environment.
- Your environment variables will be cleared when you close the terminal session. You will need to set them again when you come back.
- To resume later, open the project folder in VS Code, activate the virtual environment with source venv/bin/activate, and re-export your API keys.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Run deactivate to exit the virtual environment.
- Delete the entire job-match-ai project folder. This removes all Python files, the virtual environment, the database, and the generated dashboard.
- To revoke your Gemini API key, go to Google AI Studio and delete the key.
- To delete your Adzuna account, go to Adzuna Developer and manage your account settings.
Mission accomplished
That's a Wrap!
Nice work! You just built an AI-powered job matching assistant that reads your resume, fetches live job listings from the Adzuna API, scores each one with Google Gemini AI, stores everything in a SQLite database, and presents your best matches in a hand-coded HTML/CSS/JS dashboard.
You learned how to:
- Consumed a real REST API (Adzuna) to fetch live data, integrated Google Gemini AI for intelligent scoring with prompt engineering, and stored results in a SQLite database with full CRUD operations.
- Built an interactive web page from scratch using HTML for structure, CSS (Grid, Flexbox, responsive design) for styling, and JavaScript (DOM manipulation, event listeners) for filtering interactivity.
- Architected a multi-module Python project with clean separation of concerns, virtual environments, environment variable security, and command-line argument parsing.
- Built a micro HTTP server demonstrating client-server architecture, with a "Mark as Applied" button that updates the database from the browser using JavaScript fetch().
Ready to quiz yourself?