Upgrade Job Matcher CLI to Flask

Convert your job matcher CLI into a Flask web app with auth and ATS scoring.

Introduction

30 Second Summary

Applying for jobs online means copying your resume details into form after form, with no easy way to know which roles actually match your skills. What if your job matcher could live in the browser and tell you exactly which keywords you're missing?

In this project, you will transform your existing command-line job matcher into a full web application powered by Flask. You will add user login, browser-based resume upload, and a new ATS keyword scoring engine that works alongside your existing Ollama AI scores.

What You'll Build

A Flask web application that replaces your CLI workflow with browser-based job searching, resume uploading, and dual-scored results using both Ollama AI analysis and TF-IDF cosine similarity.

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

  • A Flask web server that serves your job matcher dashboard at localhost:5000 with user registration and login.
  • A browser-based resume upload and job search form that replaces your CLI workflow entirely.
  • Every job listing scored with both an Ollama AI score and a TF-IDF ATS keyword score, displayed side by side on your dashboard with color coding and keyword analysis.
  • Secret Mission: Add a "Download Report" button that generates a downloadable HTML report of your top job matches with both scores and direct apply links.

Are there any prerequisites?

Yes. This project builds on the original Job Matcher CLI project. You need a working Adzuna API key, Ollama running locally with the llama3 model, and your existing SQLite database and HTML dashboard from that project.

Before We Start

Before We Start...

Install Flask and New Dependencies

Your Job Matcher currently lives in the terminal. To search for jobs, you run a Python script, pass in arguments, and read results from the command line.

To turn this into a real web application, you need a web framework that can serve pages in the browser, handle form submissions, and manage user sessions. That framework is Flask. In this step, you will install Flask and four other packages that power the features you will build throughout this project.

In this step, get ready to:
  • Install Flask, Flask-Login, bcrypt, pypdf, and scikit-learn.
  • Verify every package imports without errors.
  • Reorganize your project folder so Flask can find your templates and static files.
Install the new Python packages

You need five new packages on top of what your existing project already has. Each one handles a different part of the web app you are about to build.

  • Open your terminal inside your Job Matcher project folder.
  • Install all five packages at once by running this command:
pip install flask==3.1.3 flask-login==0.6.3 bcrypt==4.3.0 pypdf==6.10.2 scikit-learn==1.8.0

What do these packages do?

  • flask is the web framework. It turns your Python code into a web server that handles browser requests and serves HTML pages.
  • flask-login adds user authentication. It manages login sessions so each user gets their own results.
  • bcrypt securely hashes passwords. It makes sure you never store raw passwords in your database.
  • pypdf extracts text from PDF files. You will use it to read resume uploads.
  • scikit-learn is a machine learning library. You will use its TF-IDF tools to calculate ATS keyword match scores.
  • Wait for the installation to finish. You should see Successfully installed followed by a list of all installed packages at the bottom of the terminal output.

Seeing a permissions error?

If you see Permission denied, try running the command with pip install --user instead. If you are using a virtual environment, make sure it is activated first.

If pip is not recognized, try pip3 instead. Some systems use pip3 for Python 3 packages.

Help me fix my pip installation error

Verify each package imports correctly

A successful pip install does not always mean everything is working. Some packages have system-level dependencies that can silently fail. The fastest way to confirm is to import each one in a Python shell.

  • Open a Python shell by running this command:
python3
  • You should see the >>> prompt, meaning you are inside the interactive Python shell.
  • Test all five imports by running these lines one at a time:
import flask
import flask_login
import bcrypt
import pypdf
import sklearn

What should I see?

If everything is installed correctly, each line completes silently and returns you to the >>> prompt with no output. No output is the success signal here.

  • Exit the Python shell by running:
exit()

Seeing a ModuleNotFoundError?

If any import fails with ModuleNotFoundError, your python3 command might be pointing to a different Python installation than your pip. Exit the shell and try running python (without the 3) instead.

If you are using a virtual environment, make sure it is the same one where you ran pip install.

Help me fix my Python import error

Reorganize your project folder for Flask

Flask looks for HTML files in a folder called templates/ and for CSS and JavaScript files in a folder called static/. Your existing project has these files loose in the project root, so you need to move them into the folders Flask expects.

  • Make sure your terminal is inside your Job Matcher project folder.
  • Create the two new directories by running these commands:
mkdir templates
mkdir static
  • Confirm the folders were created by running:
ls
  • You should see templates and static listed alongside your existing project files.

Why does Flask need these specific folders?

Flask uses a convention-over-configuration approach. When you call render_template("dashboard.html"), Flask automatically looks inside the templates/ folder.

Static assets like CSS and JS are served from static/ using a special URL helper. This keeps your project organized and lets Flask find files without extra configuration.

Now move your existing files into the new folders.

macOS

  • Move your existing HTML file into the templates/ folder by running:
mv *.html templates/
  • Move your existing CSS and JavaScript files into the static/ folder by running:
mv *.css static/
mv *.js static/

Windows

  • Move your existing HTML file into the templates\ folder by running:
move *.html templates\
  • Move your existing CSS and JavaScript files into the static\ folder by running:
move *.css static\
move *.js static\
  • Verify the files moved correctly by checking both folders:
ls templates/
ls static/
  • You should see your HTML file listed inside templates/ and your CSS and JS files listed inside static/.

No files moved or seeing an error?

If you see No such file or directory, make sure you are running the commands from your project root folder (the one that contains your HTML and CSS files). Run ls to confirm you can see your files before moving them.

If the wildcard did not match any files, your files might have a different extension. Move them individually instead by replacing *.html with the exact filename (for example, mv dashboard.html templates/).

Help me move my project files into the right folders

Your project is ready for Flask. All packages are installed, every import works, and your files are in the folders Flask expects. Next up, you will create the Flask app itself and serve your dashboard in the browser.

Create the Flask App and Serve Your Dashboard

Your packages are installed and your project folder is organized. Now it is time to build the actual web application.

Right now, your job matcher only works in the terminal. In this step, you will create a Flask web server that serves your dashboard in the browser and exposes a JSON API for your job data. You will also set up the database schema that powers user accounts and job storage.

In this step, get ready to:
  • Create app.py with a Flask server that renders your dashboard at the root URL.
  • Add an API endpoint that returns job data as JSON for your dashboard JavaScript.
  • Set up the SQLite database schema with tables for users and jobs.
Create app.py with Flask configuration

Flask needs a central file that creates the web application, configures it, and defines routes. This file is app.py.

  • Create a new file called app.py in the root of your project folder (the same level as your templates/ and static/ folders).
  • Add the following imports and app configuration to the top of app.py:
import os
import json
import sqlite3
import secrets
import requests
import bcrypt
from flask import (
    Flask, render_template, request, redirect,
    url_for, flash, jsonify, session, make_response
)
from flask_login import (
    LoginManager, UserMixin, login_user,
    logout_user, login_required, current_user
)
from werkzeug.utils import secure_filename
from pypdf import PdfReader
from ollama import chat

What are all these imports for?

  • flask provides the web framework. Functions like render_template serve HTML, and jsonify sends JSON responses.
  • flask_login handles user sessions, login, and route protection. You will wire these up in Step 3.
  • pypdf and ollama are imported now so they are ready when you build resume upload and AI scoring in later steps.

Now add the app configuration, database helpers, and the LoginManager setup below the imports.

  • Copy the following code below your imports in app.py:
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024  # 5 MB limit

DATABASE = os.path.join(os.path.dirname(__file__), "jobs.db")
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "your-app-id-here")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "your-app-key-here")
OLLAMA_MODEL = "llama3"

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"


# --- Database helpers ---

def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn

What does this configuration do?

  • app.secret_key generates a random key that Flask uses to sign session cookies. Without it, flash messages and login sessions would not work.
  • UPLOAD_FOLDER creates a folder for uploaded resumes. MAX_CONTENT_LENGTH caps file uploads at 5 MB so a huge file cannot crash your server.
  • ADZUNA_APP_ID and ADZUNA_APP_KEY read your API credentials from environment variables so they stay out of your source code.
  • LoginManager is the Flask-Login component that tracks who is logged in. Setting login_view = "login" tells it where to redirect unauthenticated users. You will create that route in Step 3.
  • get_db() opens a connection to your SQLite database. Setting row_factory = sqlite3.Row lets you access columns by name (like row["title"]) instead of by index number.

Your Flask app now has its foundation. Next, add the first route so the app can serve your dashboard.

  • Add the dashboard route below the get_db() function in app.py:
# --- Dashboard ---

@app.route("/")
def dashboard():
    return render_template("dashboard.html")

What does this route do?

@app.route("/") tells Flask to call this function when someone visits the root URL (http://127.0.0.1:5000/). render_template("dashboard.html") looks inside your templates/ folder for that file and sends it to the browser.

Your dashboard HTML still has hardcoded paths to its CSS and JS files from the old CLI project. Flask serves static files from the static/ folder, so you need to update those references to use Flask's url_for() helper.

  • Open your dashboard HTML file in the templates/ folder.
  • Find the <link> tag that references your CSS file.
  • Update the href to use Flask's template syntax, so it looks like this:
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
  • Do the same for any <script> tags that reference JS files in your static/ folder. Replace the hardcoded path with {{ url_for('static', filename='your-file.js') }}.

Why use url_for instead of a regular path?

The {{ url_for('static', filename='...') }} syntax is a Jinja2 template expression. Flask processes these before sending the HTML to the browser, replacing them with the correct URL for your static files.

This means your links always work regardless of how Flask is deployed. Hardcoded paths break if you ever change your folder structure or host the app somewhere else.

  • Save both app.py and your dashboard HTML file.

Seeing a red underline on an import?

Your editor might show a warning on from ollama import chat or from pypdf import PdfReader. Make sure your editor is using the same Python environment where you installed the packages in Step 1.

help me fix import errors in my Flask app

Add the jobs API endpoint and test your app

Your dashboard JavaScript needs a way to fetch job data from the server. Instead of loading a static file, it will call an API endpoint that queries your SQLite database and returns JSON.

  • Add the /api/jobs route below the dashboard route in app.py:
# --- API endpoint for dashboard JS ---

@app.route("/api/jobs")
def api_jobs():
    conn = get_db()
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs ORDER BY (ai_score + ats_score) DESC"""
    ).fetchall()
    conn.close()
    jobs = []
    for row in rows:
        jobs.append({
            "id": row["id"],
            "adzuna_id": row["adzuna_id"],
            "title": row["title"],
            "company": row["company"],
            "location": row["location"],
            "description": row["description"],
            "salary_min": row["salary_min"],
            "salary_max": row["salary_max"],
            "url": row["url"],
            "ai_score": row["ai_score"],
            "ats_score": row["ats_score"],
            "matched_keywords": json.loads(row["matched_keywords"]),
            "missing_keywords": json.loads(row["missing_keywords"]),
            "applied": row["applied"]
        })
    return jsonify(jobs)

What does this endpoint do?

  • The route queries the jobs table and selects every column the dashboard needs, sorted by the combined AI + ATS score.
  • json.loads() converts the matched_keywords and missing_keywords from stored JSON strings back into Python lists, so the API sends proper arrays.
  • jsonify() wraps the list in a proper JSON HTTP response with the correct content type header.

Your app needs an entry point so you can run it. The standard Python pattern is an if __name__ == "__main__" block at the bottom of the file.

  • Add the following at the very bottom of app.py:
# --- Init and run ---

if __name__ == "__main__":
    app.run(debug=True)

What does debug=True do?

Debug mode automatically restarts the server whenever you save a file. It also shows detailed error pages in your browser instead of a generic 500 error. Always use it during development, but never in production.

  • Save app.py.
  • Start the Flask development server by running this command in your terminal:
flask --app app run --debug
  • Open your browser and go to http://127.0.0.1:5000.

You should see your existing dashboard HTML rendered in the browser. The job cards may be empty or show an error in the browser console because the database schema has not been updated yet. That is expected and you will fix it next.

Dashboard not loading?

  • If the terminal shows TemplateNotFound, make sure your HTML file is inside the templates/ folder and that the filename in render_template() matches it exactly.
  • If your CSS is not loading (the page looks unstyled), check that the {{ url_for('static', filename='style.css') }} path matches the actual filename in your static/ folder.
  • If you see Address already in use, another process is already running on port 5000. Stop it with Ctrl+C in that terminal, then try again.

help me debug my Flask server startup

Set up the database schema for users and jobs

Your old CLI project stored jobs in a simple table. The web app needs a more structured schema. You need a users table for authentication and an upgraded jobs table that links each job to a specific user and stores both scoring types.

  • Stop the Flask server by pressing Ctrl+C in your terminal.
  • In app.py, add the init_db() function below the get_db() function and above the dashboard route:
def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            resume_text TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            adzuna_id TEXT NOT NULL,
            title TEXT,
            company TEXT,
            location TEXT,
            description TEXT,
            salary_min REAL,
            salary_max REAL,
            url TEXT,
            ai_score REAL DEFAULT 0,
            ats_score REAL DEFAULT 0,
            matched_keywords TEXT DEFAULT '[]',
            missing_keywords TEXT DEFAULT '[]',
            applied INTEGER DEFAULT 0,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            UNIQUE(user_id, adzuna_id)
        )
    """)
    conn.commit()
    conn.close()

What do these tables store?

The users table holds account data: a unique username, a hashed password (never plain text), and the extracted text from the user's uploaded resume.

The jobs table stores every job result with its Adzuna ID, both scores (ai_score and ats_score), and lists of matched and missing keywords. The UNIQUE(user_id, adzuna_id) constraint prevents duplicate job entries for the same user.

The database tables need to be created when the app starts. You also need to protect routes so only logged-in users can access them.

  • Scroll to the bottom of app.py and find the if __name__ block.
  • Find this line:
if __name__ == "__main__":
    app.run(debug=True)
  • Add init_db() before app.run() so the block now looks like this:
if __name__ == "__main__":
    init_db()
    app.run(debug=True)

Now protect both routes so only authenticated users can access them. This prepares your app for the login system you will build in the next step.

  • Find the dashboard route. It currently looks like this:
@app.route("/")
def dashboard():
    return render_template("dashboard.html")
  • Replace the entire dashboard function with this updated version that adds login protection and passes user data to the template:
@app.route("/")
@login_required
def dashboard():
    has_resume = current_user.resume_text is not None and len(current_user.resume_text) > 0
    return render_template("dashboard.html", has_resume=has_resume, username=current_user.username)
  • Find the /api/jobs route. Find the line that starts with @app.route("/api/jobs").
  • Add @login_required on the line right below @app.route("/api/jobs") and above def api_jobs():.
  • Update the SQL query inside api_jobs() to filter by the logged-in user. Replace the entire rows = conn.execute(...) call so it looks like this:
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs WHERE user_id = ? ORDER BY (ai_score + ats_score) DESC""",
        (current_user.id,)
    ).fetchall()

Why add login protection now?

@login_required is a Flask-Login decorator that blocks unauthenticated requests. When someone visits / without being logged in, Flask redirects them to the login page instead.

Adding WHERE user_id = ? to the query means each user only sees their own job results. The current_user.id value comes from Flask-Login's session system. You will build the login page in the next step.

  • Save app.py.

Not sure where init_db() goes?

The init_db() function should sit between get_db() and the dashboard route. It uses get_db() internally, so it must come after that function. Use the full code tab below to double check your file structure.

help me organize the functions in my app.py file

✔️ Awesome, I've got everything!

Double check you have saved app.py. Your file should have imports at the top, then app configuration, then get_db(), then init_db(), then both routes, then the if __name__ block at the bottom.

ⓧ I'd like to double check the full code

import os
import json
import sqlite3
import secrets
import requests
import bcrypt
from flask import (
    Flask, render_template, request, redirect,
    url_for, flash, jsonify, session, make_response
)
from flask_login import (
    LoginManager, UserMixin, login_user,
    logout_user, login_required, current_user
)
from werkzeug.utils import secure_filename
from pypdf import PdfReader
from ollama import chat

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024

DATABASE = os.path.join(os.path.dirname(__file__), "jobs.db")
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "your-app-id-here")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "your-app-key-here")
OLLAMA_MODEL = "llama3"

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"

def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn

def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            resume_text TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            adzuna_id TEXT NOT NULL,
            title TEXT,
            company TEXT,
            location TEXT,
            description TEXT,
            salary_min REAL,
            salary_max REAL,
            url TEXT,
            ai_score REAL DEFAULT 0,
            ats_score REAL DEFAULT 0,
            matched_keywords TEXT DEFAULT '[]',
            missing_keywords TEXT DEFAULT '[]',
            applied INTEGER DEFAULT 0,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            UNIQUE(user_id, adzuna_id)
        )
    """)
    conn.commit()
    conn.close()

@app.route("/")
@login_required
def dashboard():
    has_resume = current_user.resume_text is not None and len(current_user.resume_text) > 0
    return render_template("dashboard.html", has_resume=has_resume, username=current_user.username)

@app.route("/api/jobs")
@login_required
def api_jobs():
    conn = get_db()
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs WHERE user_id = ? ORDER BY (ai_score + ats_score) DESC""",
        (current_user.id,)
    ).fetchall()
    conn.close()
    jobs = []
    for row in rows:
        jobs.append({
            "id": row["id"],
            "adzuna_id": row["adzuna_id"],
            "title": row["title"],
            "company": row["company"],
            "location": row["location"],
            "description": row["description"],
            "salary_min": row["salary_min"],
            "salary_max": row["salary_max"],
            "url": row["url"],
            "ai_score": row["ai_score"],
            "ats_score": row["ats_score"],
            "matched_keywords": json.loads(row["matched_keywords"]),
            "missing_keywords": json.loads(row["missing_keywords"]),
            "applied": row["applied"]
        })
    return jsonify(jobs)

if __name__ == "__main__":
    init_db()
    app.run(debug=True)

Your Flask server is wired up with a proper database schema, a dashboard route, and a jobs API. Next up, you will build the user registration and login system so people can actually create accounts and access the dashboard.

Add User Registration and Login

Your Flask app is serving the dashboard, but right now anyone who visits http://127.0.0.1:5000 gets redirected to a login page that does not exist yet. The @login_required decorator you added in the last step is blocking access, which is exactly what we want.

In this step, you will build the authentication system that lets users register, log in, and log out. Flask-Login manages user sessions for you, so you only need to define what a "user" looks like and how to verify their password.

In this step, get ready to:
  • Configure Flask-Login with a User model and session loader.
  • Build a registration page with password hashing.
  • Build login and logout routes to protect the dashboard.
Create the User model and session loader

Flask-Login needs two things to manage sessions. It needs a User class that represents a logged-in person, and a loader function that can look up a user from the database by their ID.

  • In app.py, find the closing line of the init_db() function:
    conn.commit()
    conn.close()
  • Add the following code below init_db(), before the @app.route("/") dashboard route:
# --- User model for Flask-Login ---

class User(UserMixin):
    def __init__(self, id, username, resume_text=None):
        self.id = id
        self.username = username
        self.resume_text = resume_text


@login_manager.user_loader
def load_user(user_id):
    conn = get_db()
    row = conn.execute(
        "SELECT id, username, resume_text FROM users WHERE id = ?",
        (user_id,)
    ).fetchone()
    conn.close()
    if row:
        return User(row["id"], row["username"], row["resume_text"])
    return None

What does this code do?

The User class inherits from UserMixin, which gives Flask-Login the helper methods it needs (like is_authenticated and get_id()) without you writing them yourself.

The @login_manager.user_loader decorator tells Flask-Login how to reload a user from the database on every request. When someone visits a page, Flask-Login calls this function with the stored user ID from the session cookie.

  • Save app.py.

You will not see a visible change yet. The next chunk builds the registration page that uses this User model.

Build the registration system

Before users can log in, they need a way to create an account. You will build a registration page with a username and password form, then add a route that hashes the password and stores it in the database.

  • Create a new file called register.html inside your templates/ folder.
  • Paste the following HTML into templates/register.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Register - Job Matcher</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
        .card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 400px; }
        h1 { margin-bottom: 1.5rem; text-align: center; color: #38bdf8; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }
        label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: #94a3b8; }
        input[type="text"], input[type="password"] { width: 100%; padding: 0.75rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; margin-bottom: 1rem; font-size: 1rem; }
        button { width: 100%; padding: 0.75rem; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
        button:hover { background: #1d4ed8; }
        .link { text-align: center; margin-top: 1rem; }
        .link a { color: #38bdf8; text-decoration: none; }
    </style>
</head>
<body>
    <div class="card">
        <h1>Create Account</h1>
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
        <form method="POST" action="{{ url_for('register') }}">
            <label for="username">Username</label>
            <input type="text" id="username" name="username" required>
            <label for="password">Password</label>
            <input type="password" id="password" name="password" required minlength="6">
            <button type="submit">Register</button>
        </form>
        <div class="link">
            <p>Already have an account? <a href="{{ url_for('login') }}">Log in</a></p>
        </div>
    </div>
</body>
</html>

What does this template do?

This is a Jinja2 template. Flask uses Jinja2 as its template engine, which lets you mix Python logic into HTML using {% %} and {{ }} tags.

The get_flashed_messages() block displays success or error messages that your Python routes send using flash(). The form submits a POST request back to the /register route, which you will create next.

  • Save templates/register.html.

You will not see this page in the browser yet. The next chunk adds the route that serves it.

  • Back in app.py, find the load_user() function you just added. Add the following /register route below it, before the @app.route("/") dashboard route:
# --- Auth routes ---

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        if not username or not password:
            flash("Username and password are required.", "error")
            return redirect(url_for("register"))
        hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
        conn = get_db()
        try:
            conn.execute(
                "INSERT INTO users (username, password_hash) VALUES (?, ?)",
                (username, hashed.decode("utf-8"))
            )
            conn.commit()
        except sqlite3.IntegrityError:
            flash("Username already taken.", "error")
            conn.close()
            return redirect(url_for("register"))
        conn.close()
        flash("Account created! Please log in.", "success")
        return redirect(url_for("login"))
    return render_template("register.html")

What does this route do?

  • On a GET request, it renders the register.html template you just created.
  • On a POST request, it reads the username and password from the form, hashes the password with bcrypt, and inserts a new row into the users table.
  • The try/except sqlite3.IntegrityError block catches duplicate usernames. If someone tries to register with a name that already exists, they see a friendly error instead of a crash.

Why hash passwords with bcrypt?

Storing passwords as plain text is a major security risk. If someone accessed your database, they would see every password. bcrypt.hashpw() converts the password into a one-way hash that cannot be reversed.

Later, when a user logs in, bcrypt.checkpw() compares the new password attempt against the stored hash without ever decrypting it.

  • Save app.py.
  • Make sure your Flask dev server is running. If it is not, start it by running:
flask --app app run --debug
  • Open your browser and go to http://127.0.0.1:5000/register.

You should see a dark card with a "Create Account" heading, username and password fields, and a Register button.

Don't see the registration form?

  • Check that register.html is inside the templates/ folder, not in the project root. Flask only looks for templates in that folder.
  • Make sure you saved both app.py and templates/register.html. The debug server auto-reloads when you save Python files, but you still need to refresh your browser.
  • If you see a TemplateNotFound error in your terminal, double check the filename is exactly register.html with no typos.

Why isn't my Flask registration page loading?

Build the login and logout system

Users can register, but they cannot log in yet. You need a login page that verifies their password and a logout route that ends their session.

  • Create a new file called login.html inside your templates/ folder.
  • Paste the following HTML into templates/login.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login - Job Matcher</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
        .card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 400px; }
        h1 { margin-bottom: 1.5rem; text-align: center; color: #38bdf8; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }
        label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: #94a3b8; }
        input[type="text"], input[type="password"] { width: 100%; padding: 0.75rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; margin-bottom: 1rem; font-size: 1rem; }
        button { width: 100%; padding: 0.75rem; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
        button:hover { background: #1d4ed8; }
        .link { text-align: center; margin-top: 1rem; }
        .link a { color: #38bdf8; text-decoration: none; }
    </style>
</head>
<body>
    <div class="card">
        <h1>Job Matcher</h1>
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
        <form method="POST" action="{{ url_for('login') }}">
            <label for="username">Username</label>
            <input type="text" id="username" name="username" required>
            <label for="password">Password</label>
            <input type="password" id="password" name="password" required>
            <button type="submit">Log In</button>
        </form>
        <div class="link">
            <p>Don't have an account? <a href="{{ url_for('register') }}">Register</a></p>
        </div>
    </div>
</body>
</html>

What is different about this template?

The structure is almost identical to register.html. The key differences are the heading ("Job Matcher" instead of "Create Account"), the button text ("Log In" instead of "Register"), and the link at the bottom pointing to registration instead of login.

  • Save templates/login.html.

This template will not work until you add the login route in the next chunk.

  • Back in app.py, find the register() function you just added. Add the following two routes below it, before the @app.route("/") dashboard route:
@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        conn = get_db()
        row = conn.execute(
            "SELECT id, username, password_hash, resume_text FROM users WHERE username = ?",
            (username,)
        ).fetchone()
        conn.close()
        if row and bcrypt.checkpw(password.encode("utf-8"), row["password_hash"].encode("utf-8")):
            user = User(row["id"], row["username"], row["resume_text"])
            login_user(user)
            return redirect(url_for("dashboard"))
        flash("Invalid username or password.", "error")
        return redirect(url_for("login"))
    return render_template("login.html")


@app.route("/logout")
@login_required
def logout():
    logout_user()
    flash("You have been logged out.", "success")
    return redirect(url_for("login"))

What do these routes do?

  • The /login route looks up the user by username, then uses bcrypt.checkpw() to verify the password against the stored hash. If both match, login_user() creates a session and redirects to the dashboard.
  • If the username does not exist or the password is wrong, the route shows a generic "Invalid username or password" message. This avoids revealing whether the username exists.
  • The /logout route calls logout_user() to clear the session, then redirects back to the login page.
  • Save app.py.
  • Open your browser and go to http://127.0.0.1:5000/register.
  • Enter a username and a password (at least 6 characters), then click Register.

You should see a green "Account created! Please log in." flash message and land on the login page.

  • Enter the same username and password you just registered with, then click Log In.

You should be redirected to your dashboard. The full registration, login, and session flow is now working.

Seeing "Invalid username or password" after registering?

  • Make sure you are typing the exact same username and password you registered with. Usernames are case-sensitive.
  • Check your terminal for a Python traceback. If you see an error about bcrypt, make sure bcrypt is installed correctly by running pip install bcrypt again.
  • If you restarted your Flask server between registering and logging in, the database should still have your account. Try registering with a different username to rule out duplicates.

Why can't I log in to my Flask app after registering?

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py, templates/login.html, and templates/register.html.

ⓧ I'd like to double check the full code

Here is the full app.py as it should look at the end of this step.

import os
import json
import sqlite3
import secrets
import requests
import bcrypt
from flask import (
    Flask, render_template, request, redirect,
    url_for, flash, jsonify, session, make_response
)
from flask_login import (
    LoginManager, UserMixin, login_user,
    logout_user, login_required, current_user
)
from werkzeug.utils import secure_filename
from pypdf import PdfReader
from ollama import chat
from ats_scorer import calculate_ats_score

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024

DATABASE = os.path.join(os.path.dirname(__file__), "jobs.db")
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "your-app-id-here")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "your-app-key-here")
OLLAMA_MODEL = "llama3"

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"

def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn

def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            resume_text TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            adzuna_id TEXT NOT NULL,
            title TEXT,
            company TEXT,
            location TEXT,
            description TEXT,
            salary_min REAL,
            salary_max REAL,
            url TEXT,
            ai_score REAL DEFAULT 0,
            ats_score REAL DEFAULT 0,
            matched_keywords TEXT DEFAULT '[]',
            missing_keywords TEXT DEFAULT '[]',
            applied INTEGER DEFAULT 0,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            UNIQUE(user_id, adzuna_id)
        )
    """)
    conn.commit()
    conn.close()

class User(UserMixin):
    def __init__(self, id, username, resume_text=None):
        self.id = id
        self.username = username
        self.resume_text = resume_text

@login_manager.user_loader
def load_user(user_id):
    conn = get_db()
    row = conn.execute(
        "SELECT id, username, resume_text FROM users WHERE id = ?",
        (user_id,)
    ).fetchone()
    conn.close()
    if row:
        return User(row["id"], row["username"], row["resume_text"])
    return None

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        if not username or not password:
            flash("Username and password are required.", "error")
            return redirect(url_for("register"))
        hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
        conn = get_db()
        try:
            conn.execute(
                "INSERT INTO users (username, password_hash) VALUES (?, ?)",
                (username, hashed.decode("utf-8"))
            )
            conn.commit()
        except sqlite3.IntegrityError:
            flash("Username already taken.", "error")
            conn.close()
            return redirect(url_for("register"))
        conn.close()
        flash("Account created! Please log in.", "success")
        return redirect(url_for("login"))
    return render_template("register.html")

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        conn = get_db()
        row = conn.execute(
            "SELECT id, username, password_hash, resume_text FROM users WHERE username = ?",
            (username,)
        ).fetchone()
        conn.close()
        if row and bcrypt.checkpw(password.encode("utf-8"), row["password_hash"].encode("utf-8")):
            user = User(row["id"], row["username"], row["resume_text"])
            login_user(user)
            return redirect(url_for("dashboard"))
        flash("Invalid username or password.", "error")
        return redirect(url_for("login"))
    return render_template("login.html")

@app.route("/logout")
@login_required
def logout():
    logout_user()
    flash("You have been logged out.", "success")
    return redirect(url_for("login"))

@app.route("/")
@login_required
def dashboard():
    has_resume = current_user.resume_text is not None and len(current_user.resume_text) > 0
    return render_template("dashboard.html", has_resume=has_resume, username=current_user.username)

@app.route("/api/jobs")
@login_required
def api_jobs():
    conn = get_db()
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs WHERE user_id = ? ORDER BY (ai_score + ats_score) DESC""",
        (current_user.id,)
    ).fetchall()
    conn.close()
    jobs = []
    for row in rows:
        jobs.append({
            "id": row["id"],
            "adzuna_id": row["adzuna_id"],
            "title": row["title"],
            "company": row["company"],
            "location": row["location"],
            "description": row["description"],
            "salary_min": row["salary_min"],
            "salary_max": row["salary_max"],
            "url": row["url"],
            "ai_score": row["ai_score"],
            "ats_score": row["ats_score"],
            "matched_keywords": json.loads(row["matched_keywords"]),
            "missing_keywords": json.loads(row["missing_keywords"]),
            "applied": row["applied"]
        })
    return jsonify(jobs)

if __name__ == "__main__":
    init_db()
    app.run(debug=True)

And here is templates/register.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Register - Job Matcher</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
        .card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 400px; }
        h1 { margin-bottom: 1.5rem; text-align: center; color: #38bdf8; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }
        label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: #94a3b8; }
        input[type="text"], input[type="password"] { width: 100%; padding: 0.75rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; margin-bottom: 1rem; font-size: 1rem; }
        button { width: 100%; padding: 0.75rem; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
        button:hover { background: #1d4ed8; }
        .link { text-align: center; margin-top: 1rem; }
        .link a { color: #38bdf8; text-decoration: none; }
    </style>
</head>
<body>
    <div class="card">
        <h1>Create Account</h1>
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
        <form method="POST" action="{{ url_for('register') }}">
            <label for="username">Username</label>
            <input type="text" id="username" name="username" required>
            <label for="password">Password</label>
            <input type="password" id="password" name="password" required minlength="6">
            <button type="submit">Register</button>
        </form>
        <div class="link">
            <p>Already have an account? <a href="{{ url_for('login') }}">Log in</a></p>
        </div>
    </div>
</body>
</html>

And here is templates/login.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login - Job Matcher</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
        .card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 400px; }
        h1 { margin-bottom: 1.5rem; text-align: center; color: #38bdf8; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }
        label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: #94a3b8; }
        input[type="text"], input[type="password"] { width: 100%; padding: 0.75rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; margin-bottom: 1rem; font-size: 1rem; }
        button { width: 100%; padding: 0.75rem; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
        button:hover { background: #1d4ed8; }
        .link { text-align: center; margin-top: 1rem; }
        .link a { color: #38bdf8; text-decoration: none; }
    </style>
</head>
<body>
    <div class="card">
        <h1>Job Matcher</h1>
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
        <form method="POST" action="{{ url_for('login') }}">
            <label for="username">Username</label>
            <input type="text" id="username" name="username" required>
            <label for="password">Password</label>
            <input type="password" id="password" name="password" required>
            <button type="submit">Log In</button>
        </form>
        <div class="link">
            <p>Don't have an account? <a href="{{ url_for('register') }}">Register</a></p>
        </div>
    </div>
</body>
</html>

Your app now has a complete authentication flow. Next up, you will add resume upload so users can submit their PDF directly from the browser.

Add Resume Upload from the Browser

Your Flask app now has a full authentication system. Users can register, log in, and access the protected dashboard. The next piece of the puzzle is getting the user's resume into the app so it can be used for job scoring.

In this step, you will build a file upload page that accepts PDF resumes, extracts the text using pypdf, and stores that text in the database. You will also add a visual badge on the dashboard so users always know whether their resume is ready.

In this step, get ready to:
  • Create an upload page template with a PDF file upload form.
  • Add a Flask route that saves the PDF, extracts its text, and stores it in the database.
  • Update the dashboard with a resume status badge that shows whether a resume has been uploaded.
Create the upload page template

For a browser to send a file to your server, the HTML form needs a special encoding type called multipart/form-data. This tells the browser to package the file's binary data into the request instead of just sending text.

  • Create a new file called upload.html inside your templates/ folder, then paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upload Resume - Job Matcher</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
        .card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 500px; }
        h1 { margin-bottom: 0.5rem; color: #38bdf8; }
        p.subtitle { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }
        .upload-area { border: 2px dashed #334155; border-radius: 8px; padding: 2rem; text-align: center; margin-bottom: 1rem; }
        .upload-area input[type="file"] { margin-top: 0.5rem; }
        button { width: 100%; padding: 0.75rem; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
        button:hover { background: #1d4ed8; }
        .link { text-align: center; margin-top: 1rem; }
        .link a { color: #38bdf8; text-decoration: none; }
    </style>
</head>
<body>
    <div class="card">
        <h1>Upload Resume</h1>
        <p class="subtitle">Upload your resume as a PDF file. The text will be extracted and used for job matching.</p>
        <!-- Display flash messages from the server -->
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
        <!-- File upload form with multipart encoding -->
        <form method="POST" action="{{ url_for('upload') }}" enctype="multipart/form-data">
            <div class="upload-area">
                <p>Choose a PDF file</p>
                <input type="file" name="resume" accept=".pdf" required>
            </div>
            <button type="submit">Upload and Parse</button>
        </form>
        <div class="link">
            <a href="{{ url_for('dashboard') }}">Back to Dashboard</a>
        </div>
    </div>
</body>
</html>

What does this template do?

  • enctype="multipart/form-data" on the <form> tag tells the browser to send file data, not just text.
  • accept=".pdf" on the file input restricts the file picker to PDF files only.
  • The get_flashed_messages() block displays success or error messages sent from the server after each upload attempt.
  • Save templates/upload.html.

Template not rendering later?

Make sure the file is saved inside the templates/ folder, not the project root. Flask only looks for templates in that specific folder.

Double check the filename is exactly upload.html with no typos or extra extensions.

Help me fix a Flask template not found error.

Add the upload route to your Flask app

The upload page template is ready, but Flask does not know how to serve it yet. You need a route that handles two things: showing the upload form on GET requests, and processing the uploaded file on POST requests.

  • In app.py, find the dashboard() route function.
  • Add the following upload route directly below it, before the api_jobs() function:
# --- Resume upload ---

@app.route("/upload", methods=["GET", "POST"])
@login_required
def upload():
    if request.method == "POST":
        # Check that a file was included in the request
        if "resume" not in request.files:
            flash("No file selected.", "error")
            return redirect(url_for("upload"))
        file = request.files["resume"]
        if file.filename == "":
            flash("No file selected.", "error")
            return redirect(url_for("upload"))
        # Only accept PDF files
        if not file.filename.lower().endswith(".pdf"):
            flash("Only PDF files are accepted.", "error")
            return redirect(url_for("upload"))
        # Save the file with the user's ID prefixed to avoid name collisions
        filename = secure_filename(file.filename)
        filepath = os.path.join(app.config["UPLOAD_FOLDER"], f"{current_user.id}_{filename}")
        file.save(filepath)
        # Extract text from every page of the PDF
        try:
            reader = PdfReader(filepath)
            text = ""
            for page in reader.pages:
                page_text = page.extract_text()
                if page_text:
                    text += page_text + "\n"
        except Exception:
            flash("Could not read the PDF. Please try a different file.", "error")
            return redirect(url_for("upload"))
        # Make sure some text was actually extracted
        if not text.strip():
            flash("No text found in PDF. Make sure it is not a scanned image.", "error")
            return redirect(url_for("upload"))
        # Store the extracted resume text in the user's database row
        conn = get_db()
        conn.execute(
            "UPDATE users SET resume_text = ? WHERE id = ?",
            (text.strip(), current_user.id)
        )
        conn.commit()
        conn.close()
        flash("Resume uploaded and parsed successfully!", "success")
        return redirect(url_for("dashboard"))
    return render_template("upload.html")

What does this route do?

This route handles the full upload lifecycle in a single function. Here is what each section does.

  • File validation checks three things: that a file exists in the request, that it has a filename, and that the filename ends in .pdf. Each failure sends a flash message and redirects back to the upload form.
  • secure_filename() strips dangerous characters from the filename (like ../) to prevent directory traversal attacks. The user's ID is prefixed so two users uploading resume.pdf do not overwrite each other.
  • PdfReader from pypdf opens the saved file and loops through every page. page.extract_text() pulls out the readable text from each page.
  • The extracted text is stored in the resume_text column of the current user's row. This is what the scoring engine will use later to compare against job descriptions.
  • Save app.py.
  • Make sure your Flask dev server is running. If it is not, start it by running:
flask --app app run --debug
  • Open your browser and go to http://127.0.0.1:5000/upload. Log in if prompted.

You should see a dark card with an "Upload Resume" heading, a file picker, and an "Upload and Parse" button.

  • Click Choose File (or Browse depending on your browser) and select any PDF file from your computer.
  • Click Upload and Parse.

You should be redirected back to the dashboard with a green flash message that reads "Resume uploaded and parsed successfully!".

Seeing an error after uploading?

  • If you see "Only PDF files are accepted", make sure you selected a .pdf file and not a Word document or image.
  • If you see "No text found in PDF", your PDF might be a scanned image rather than a text-based document. Try a different PDF that contains selectable text.
  • If you see "Request Entity Too Large", your file exceeds the 5 MB limit set by MAX_CONTENT_LENGTH. Try a smaller PDF.

Help me debug my Flask file upload route.

Update the dashboard with a resume status badge

Right now, a user has no way of knowing whether their resume is uploaded without navigating to the upload page. A small colored badge on the dashboard solves this instantly.

Your dashboard() route already passes a has_resume variable to the template. Now you need the template to use it.

  • Open templates/dashboard.html and replace its entire contents with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Job Matcher Dashboard</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
    <style>
        /* Top navigation bar */
        .top-bar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: #1e293b; border-bottom: 1px solid #334155; }
        .top-bar .user-info { display: flex; align-items: center; gap: 1rem; }
        .top-bar .user-info span { color: #94a3b8; }
        .top-bar a { color: #38bdf8; text-decoration: none; font-size: 0.9rem; }
        /* Resume status badge */
        .resume-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; }
        .resume-badge.uploaded { background: #14532d; color: #86efac; }
        .resume-badge.missing { background: #7f1d1d; color: #fca5a5; }
        /* Search form */
        .search-form { display: flex; gap: 0.75rem; padding: 1rem 2rem; background: #1e293b; border-bottom: 1px solid #334155; flex-wrap: wrap; align-items: center; }
        .search-form input { padding: 0.6rem 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; font-size: 0.95rem; }
        .search-form button { padding: 0.6rem 1.5rem; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 0.95rem; }
        .search-form button:hover { background: #1d4ed8; }
        /* Flash messages */
        .flash-area { padding: 0 2rem; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }
        /* Job cards grid */
        #jobs-container { padding: 1.5rem 2rem; display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; }
        .job-card { background: #1e293b; border-radius: 12px; padding: 1.25rem; border: 1px solid #334155; }
        .job-card h3 { color: #f1f5f9; margin-bottom: 0.25rem; font-size: 1.05rem; }
        .job-card .company { color: #94a3b8; font-size: 0.9rem; margin-bottom: 0.75rem; }
        .scores { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
        .score-pill { padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
        .score-green { background: #14532d; color: #86efac; }
        .score-yellow { background: #713f12; color: #fde68a; }
        .score-red { background: #7f1d1d; color: #fca5a5; }
        .keywords { font-size: 0.8rem; margin-bottom: 0.75rem; }
        .keywords .matched { color: #86efac; }
        .keywords .missing { color: #fca5a5; }
        .job-card .actions { display: flex; gap: 0.5rem; }
        .job-card .actions a, .job-card .actions button { padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 0.85rem; text-decoration: none; cursor: pointer; border: none; }
        .btn-apply { background: #2563eb; color: white; }
        .btn-applied { background: #334155; color: #94a3b8; }
        .btn-mark { background: #0f172a; color: #38bdf8; border: 1px solid #334155 !important; }
        .loading { text-align: center; padding: 3rem; color: #94a3b8; display: none; }
    </style>
</head>
<body style="background: #0f172a; color: #e2e8f0; font-family: 'Segoe UI', sans-serif; margin: 0;">
    <!-- Top navigation bar with user info and resume badge -->
    <div class="top-bar">
        <h2 style="color: #38bdf8;">Job Matcher</h2>
        <div class="user-info">
            <span>Hello, {{ username }}</span>
            {% if has_resume %}
            <span class="resume-badge uploaded">Resume Uploaded</span>
            {% else %}
            <a href="{{ url_for('upload') }}"><span class="resume-badge missing">Upload Resume</span></a>
            {% endif %}
            <a href="{{ url_for('upload') }}">Upload Resume</a>
            <a href="{{ url_for('logout') }}">Logout</a>
        </div>
    </div>

    <!-- Search form for finding jobs -->
    <form class="search-form" method="POST" action="{{ url_for('search') }}" id="searchForm">
        <input type="text" name="role" placeholder="Job role (e.g. IT Associate)" required>
        <input type="text" name="location" placeholder="Location (e.g. Gurugram)">
        <button type="submit" id="searchBtn">Search Jobs</button>
    </form>

    <!-- Flash messages area -->
    <div class="flash-area">
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
    </div>

    <div class="loading" id="loading">Searching and scoring jobs... this may take a minute.</div>
    <div id="jobs-container"></div>

    <script>
        // Show loading indicator when search form is submitted
        document.getElementById("searchForm").addEventListener("submit", function () {
            document.getElementById("loading").style.display = "block";
            document.getElementById("searchBtn").disabled = true;
            document.getElementById("searchBtn").textContent = "Searching...";
        });

        // Fetch and display job cards from the API
        async function loadJobs() {
            const res = await fetch("/api/jobs");
            const jobs = await res.json();
            const container = document.getElementById("jobs-container");
            container.innerHTML = "";

            if (jobs.length === 0) {
                container.innerHTML = '<p style="padding: 2rem; color: #94a3b8;">No jobs found yet. Use the search bar above to find jobs.</p>';
                return;
            }

            jobs.forEach(function (job) {
                var aiColor = job.ai_score >= 60 ? "score-green" : job.ai_score >= 40 ? "score-yellow" : "score-red";
                var atsColor = job.ats_score >= 60 ? "score-green" : job.ats_score >= 40 ? "score-yellow" : "score-red";

                var matchedHtml = job.matched_keywords.length > 0
                    ? '<span class="matched">Matched: ' + job.matched_keywords.join(", ") + "</span>"
                    : "";
                var missingHtml = job.missing_keywords.length > 0
                    ? '<span class="missing">Missing: ' + job.missing_keywords.join(", ") + "</span>"
                    : "";

                var appliedBtn = job.applied
                    ? '<span class="btn-applied">Applied</span>'
                    : '<button class="btn-mark" onclick="markApplied(' + job.id + ', this)">Mark Applied</button>';

                var card = document.createElement("div");
                card.className = "job-card";
                card.innerHTML =
                    "<h3>" + job.title + "</h3>" +
                    '<div class="company">' + job.company + " &middot; " + job.location + "</div>" +
                    '<div class="scores">' +
                    '<span class="score-pill ' + aiColor + '">AI: ' + job.ai_score + "</span>" +
                    '<span class="score-pill ' + atsColor + '">ATS: ' + job.ats_score + "</span>" +
                    "</div>" +
                    '<div class="keywords">' + matchedHtml + (matchedHtml && missingHtml ? "<br>" : "") + missingHtml + "</div>" +
                    '<div class="actions">' +
                    '<a class="btn-apply" href="' + job.url + '" target="_blank">Apply</a>' +
                    appliedBtn +
                    "</div>";
                container.appendChild(card);
            });
        }

        // Mark a job as applied via the API
        async function markApplied(jobId, btn) {
            await fetch("/api/jobs/" + jobId + "/apply", { method: "POST" });
            btn.outerHTML = '<span class="btn-applied">Applied</span>';
        }

        loadJobs();
    </script>
</body>
</html>

What is the resume badge doing?

  • The {% if has_resume %} block checks the variable your dashboard() route passes in. If the user has uploaded a resume, a green "Resume Uploaded" badge appears. If not, the badge turns red and links directly to the upload page.
  • The template also includes a search form and job card grid. The search form will not work yet because the /search route does not exist. You will build that in the next step.
  • The JavaScript at the bottom calls /api/jobs to load any existing job results. If none exist yet, you will see a "No jobs found yet" placeholder.
  • Save templates/dashboard.html.
  • Go back to http://127.0.0.1:5000 in your browser and refresh the page.

You should see a dark top bar with "Job Matcher" on the left, and your username, a resume badge, an "Upload Resume" link, and a "Logout" link on the right. If you already uploaded a PDF in the previous substep, the badge will be green and say "Resume Uploaded". If not, it will be red and say "Upload Resume".

Don't see the resume badge?

  • Make sure you saved dashboard.html. Unsaved changes will not appear on refresh.
  • Check that has_resume and username are being passed in the render_template() call inside your dashboard() route in app.py.
  • If the badge shows red even though you uploaded a resume, try logging out and logging back in. Flask-Login reloads the user object on login, which picks up the updated resume_text.

Help me debug my dashboard resume badge.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app.py and templates/dashboard.html.

ⓧ I'd like to double check the full code

Here is the full app.py including the new /upload route:

import os
import json
import sqlite3
import secrets
import requests
import bcrypt
from flask import (
    Flask, render_template, request, redirect,
    url_for, flash, jsonify, session, make_response
)
from flask_login import (
    LoginManager, UserMixin, login_user,
    logout_user, login_required, current_user
)
from werkzeug.utils import secure_filename
from pypdf import PdfReader
from ollama import chat
from ats_scorer import calculate_ats_score

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024  # 5 MB limit

DATABASE = os.path.join(os.path.dirname(__file__), "jobs.db")
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "your-app-id-here")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "your-app-key-here")
OLLAMA_MODEL = "llama3"

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"


# --- Database helpers ---

def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            resume_text TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            adzuna_id TEXT NOT NULL,
            title TEXT,
            company TEXT,
            location TEXT,
            description TEXT,
            salary_min REAL,
            salary_max REAL,
            url TEXT,
            ai_score REAL DEFAULT 0,
            ats_score REAL DEFAULT 0,
            matched_keywords TEXT DEFAULT '[]',
            missing_keywords TEXT DEFAULT '[]',
            applied INTEGER DEFAULT 0,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            UNIQUE(user_id, adzuna_id)
        )
    """)
    conn.commit()
    conn.close()


# --- User model for Flask-Login ---

class User(UserMixin):
    def __init__(self, id, username, resume_text=None):
        self.id = id
        self.username = username
        self.resume_text = resume_text


@login_manager.user_loader
def load_user(user_id):
    conn = get_db()
    row = conn.execute(
        "SELECT id, username, resume_text FROM users WHERE id = ?",
        (user_id,)
    ).fetchone()
    conn.close()
    if row:
        return User(row["id"], row["username"], row["resume_text"])
    return None


# --- Auth routes ---

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        if not username or not password:
            flash("Username and password are required.", "error")
            return redirect(url_for("register"))
        hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
        conn = get_db()
        try:
            conn.execute(
                "INSERT INTO users (username, password_hash) VALUES (?, ?)",
                (username, hashed.decode("utf-8"))
            )
            conn.commit()
        except sqlite3.IntegrityError:
            flash("Username already taken.", "error")
            conn.close()
            return redirect(url_for("register"))
        conn.close()
        flash("Account created! Please log in.", "success")
        return redirect(url_for("login"))
    return render_template("register.html")


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        conn = get_db()
        row = conn.execute(
            "SELECT id, username, password_hash, resume_text FROM users WHERE username = ?",
            (username,)
        ).fetchone()
        conn.close()
        if row and bcrypt.checkpw(password.encode("utf-8"), row["password_hash"].encode("utf-8")):
            user = User(row["id"], row["username"], row["resume_text"])
            login_user(user)
            return redirect(url_for("dashboard"))
        flash("Invalid username or password.", "error")
        return redirect(url_for("login"))
    return render_template("login.html")


@app.route("/logout")
@login_required
def logout():
    logout_user()
    flash("You have been logged out.", "success")
    return redirect(url_for("login"))


# --- Dashboard ---

@app.route("/")
@login_required
def dashboard():
    has_resume = current_user.resume_text is not None and len(current_user.resume_text) > 0
    return render_template("dashboard.html", has_resume=has_resume, username=current_user.username)


# --- Resume upload ---

@app.route("/upload", methods=["GET", "POST"])
@login_required
def upload():
    if request.method == "POST":
        if "resume" not in request.files:
            flash("No file selected.", "error")
            return redirect(url_for("upload"))
        file = request.files["resume"]
        if file.filename == "":
            flash("No file selected.", "error")
            return redirect(url_for("upload"))
        if not file.filename.lower().endswith(".pdf"):
            flash("Only PDF files are accepted.", "error")
            return redirect(url_for("upload"))
        filename = secure_filename(file.filename)
        filepath = os.path.join(app.config["UPLOAD_FOLDER"], f"{current_user.id}_{filename}")
        file.save(filepath)
        try:
            reader = PdfReader(filepath)
            text = ""
            for page in reader.pages:
                page_text = page.extract_text()
                if page_text:
                    text += page_text + "\n"
        except Exception:
            flash("Could not read the PDF. Please try a different file.", "error")
            return redirect(url_for("upload"))
        if not text.strip():
            flash("No text found in PDF. Make sure it is not a scanned image.", "error")
            return redirect(url_for("upload"))
        conn = get_db()
        conn.execute(
            "UPDATE users SET resume_text = ? WHERE id = ?",
            (text.strip(), current_user.id)
        )
        conn.commit()
        conn.close()
        flash("Resume uploaded and parsed successfully!", "success")
        return redirect(url_for("dashboard"))
    return render_template("upload.html")


# --- API endpoint for dashboard JS ---

@app.route("/api/jobs")
@login_required
def api_jobs():
    conn = get_db()
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs WHERE user_id = ? ORDER BY (ai_score + ats_score) DESC""",
        (current_user.id,)
    ).fetchall()
    conn.close()
    jobs = []
    for row in rows:
        jobs.append({
            "id": row["id"],
            "adzuna_id": row["adzuna_id"],
            "title": row["title"],
            "company": row["company"],
            "location": row["location"],
            "description": row["description"],
            "salary_min": row["salary_min"],
            "salary_max": row["salary_max"],
            "url": row["url"],
            "ai_score": row["ai_score"],
            "ats_score": row["ats_score"],
            "matched_keywords": json.loads(row["matched_keywords"]),
            "missing_keywords": json.loads(row["missing_keywords"]),
            "applied": row["applied"]
        })
    return jsonify(jobs)


# --- Init and run ---

if __name__ == "__main__":
    init_db()
    app.run(debug=True)

Here is the full templates/upload.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upload Resume - Job Matcher</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
        .card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 500px; }
        h1 { margin-bottom: 0.5rem; color: #38bdf8; }
        p.subtitle { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }
        .upload-area { border: 2px dashed #334155; border-radius: 8px; padding: 2rem; text-align: center; margin-bottom: 1rem; }
        .upload-area input[type="file"] { margin-top: 0.5rem; }
        button { width: 100%; padding: 0.75rem; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
        button:hover { background: #1d4ed8; }
        .link { text-align: center; margin-top: 1rem; }
        .link a { color: #38bdf8; text-decoration: none; }
    </style>
</head>
<body>
    <div class="card">
        <h1>Upload Resume</h1>
        <p class="subtitle">Upload your resume as a PDF file. The text will be extracted and used for job matching.</p>
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
        <form method="POST" action="{{ url_for('upload') }}" enctype="multipart/form-data">
            <div class="upload-area">
                <p>Choose a PDF file</p>
                <input type="file" name="resume" accept=".pdf" required>
            </div>
            <button type="submit">Upload and Parse</button>
        </form>
        <div class="link">
            <a href="{{ url_for('dashboard') }}">Back to Dashboard</a>
        </div>
    </div>
</body>
</html>

Your app can now accept PDF resumes and extract their text for scoring. Next up, you will build the web-based job search form that calls the Adzuna API and scores every result against this stored resume text.

Build the Web-Based Job Search Form

Your resume is uploaded and stored as text in the database. The next piece of the puzzle is letting users search for jobs directly from the browser instead of the command line.

In this step, you'll build a search form that takes a job role and location, calls the Adzuna API to fetch real listings, and scores each one with Ollama. Results get saved to your database and displayed as job cards on the dashboard.

In this step, get ready to:
  • Add a job search form with a loading spinner to your dashboard.
  • Create a /search route that calls the Adzuna API and scores jobs with Ollama.
  • Display user-specific search results as styled job cards.
Add the search form and job cards to your dashboard

Your dashboard currently shows user info and a resume badge. You need a search form where users type a job role and location, plus the front-end logic to render scored job cards once results come back from the server.

  • Open templates/dashboard.html in your code editor.
  • Replace the entire contents of the file with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Job Matcher Dashboard</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
    <style>
        /* Top navigation bar */
        .top-bar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: #1e293b; border-bottom: 1px solid #334155; }
        .top-bar .user-info { display: flex; align-items: center; gap: 1rem; }
        .top-bar .user-info span { color: #94a3b8; }
        .top-bar a { color: #38bdf8; text-decoration: none; font-size: 0.9rem; }
        .resume-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; }
        .resume-badge.uploaded { background: #14532d; color: #86efac; }
        .resume-badge.missing { background: #7f1d1d; color: #fca5a5; }

        /* Search form row */
        .search-form { display: flex; gap: 0.75rem; padding: 1rem 2rem; background: #1e293b; border-bottom: 1px solid #334155; flex-wrap: wrap; align-items: center; }
        .search-form input { padding: 0.6rem 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; font-size: 0.95rem; }
        .search-form button { padding: 0.6rem 1.5rem; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 0.95rem; }
        .search-form button:hover { background: #1d4ed8; }

        /* Flash messages */
        .flash-area { padding: 0 2rem; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }

        /* Job card grid */
        #jobs-container { padding: 1.5rem 2rem; display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; }
        .job-card { background: #1e293b; border-radius: 12px; padding: 1.25rem; border: 1px solid #334155; }
        .job-card h3 { color: #f1f5f9; margin-bottom: 0.25rem; font-size: 1.05rem; }
        .job-card .company { color: #94a3b8; font-size: 0.9rem; margin-bottom: 0.75rem; }

        /* Score pills with color coding */
        .scores { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
        .score-pill { padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
        .score-green { background: #14532d; color: #86efac; }
        .score-yellow { background: #713f12; color: #fde68a; }
        .score-red { background: #7f1d1d; color: #fca5a5; }

        /* Keyword tags */
        .keywords { font-size: 0.8rem; margin-bottom: 0.75rem; }
        .keywords .matched { color: #86efac; }
        .keywords .missing { color: #fca5a5; }

        /* Action buttons on each card */
        .job-card .actions { display: flex; gap: 0.5rem; }
        .job-card .actions a, .job-card .actions button { padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 0.85rem; text-decoration: none; cursor: pointer; border: none; }
        .btn-apply { background: #2563eb; color: white; }
        .btn-applied { background: #334155; color: #94a3b8; }
        .btn-mark { background: #0f172a; color: #38bdf8; border: 1px solid #334155 !important; }

        /* Loading spinner */
        .loading { text-align: center; padding: 3rem; color: #94a3b8; display: none; }
    </style>
</head>
<body style="background: #0f172a; color: #e2e8f0; font-family: 'Segoe UI', sans-serif; margin: 0;">
    <!-- Top navigation bar -->
    <div class="top-bar">
        <h2 style="color: #38bdf8;">Job Matcher</h2>
        <div class="user-info">
            <span>Hello, {{ username }}</span>
            {% if has_resume %}
            <span class="resume-badge uploaded">Resume Uploaded</span>
            {% else %}
            <a href="{{ url_for('upload') }}"><span class="resume-badge missing">Upload Resume</span></a>
            {% endif %}
            <a href="{{ url_for('upload') }}">Upload Resume</a>
            <a href="{{ url_for('logout') }}">Logout</a>
        </div>
    </div>

    <!-- Search form -->
    <form class="search-form" method="POST" action="{{ url_for('search') }}" id="searchForm">
        <input type="text" name="role" placeholder="Job role (e.g. IT Associate)" required>
        <input type="text" name="location" placeholder="Location (e.g. Gurugram)">
        <button type="submit" id="searchBtn">Search Jobs</button>
    </form>

    <!-- Flash messages -->
    <div class="flash-area">
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
    </div>

    <!-- Loading indicator and job results -->
    <div class="loading" id="loading">Searching and scoring jobs... this may take a minute.</div>
    <div id="jobs-container"></div>

    <script>
        // Disable the search button and show loading on submit
        document.getElementById("searchForm").addEventListener("submit", function () {
            document.getElementById("loading").style.display = "block";
            document.getElementById("searchBtn").disabled = true;
            document.getElementById("searchBtn").textContent = "Searching...";
        });

        // Fetch jobs from the API and render cards
        async function loadJobs() {
            const res = await fetch("/api/jobs");
            const jobs = await res.json();
            const container = document.getElementById("jobs-container");
            container.innerHTML = "";

            if (jobs.length === 0) {
                container.innerHTML = '<p style="padding: 2rem; color: #94a3b8;">No jobs found yet. Use the search bar above to find jobs.</p>';
                return;
            }

            jobs.forEach(function (job) {
                // Pick color based on score thresholds
                const aiColor = job.ai_score >= 60 ? "score-green" : job.ai_score >= 40 ? "score-yellow" : "score-red";
                const atsColor = job.ats_score >= 60 ? "score-green" : job.ats_score >= 40 ? "score-yellow" : "score-red";

                const matchedHtml = job.matched_keywords.length > 0
                    ? '<span class="matched">Matched: ' + job.matched_keywords.join(", ") + "</span>"
                    : "";
                const missingHtml = job.missing_keywords.length > 0
                    ? '<span class="missing">Missing: ' + job.missing_keywords.join(", ") + "</span>"
                    : "";

                const appliedBtn = job.applied
                    ? '<span class="btn-applied">Applied</span>'
                    : '<button class="btn-mark" onclick="markApplied(' + job.id + ', this)">Mark Applied</button>';

                // Build the card HTML
                const card = document.createElement("div");
                card.className = "job-card";
                card.innerHTML =
                    "<h3>" + job.title + "</h3>" +
                    '<div class="company">' + job.company + " &middot; " + job.location + "</div>" +
                    '<div class="scores">' +
                    '<span class="score-pill ' + aiColor + '">AI: ' + job.ai_score + "</span>" +
                    '<span class="score-pill ' + atsColor + '">ATS: ' + job.ats_score + "</span>" +
                    "</div>" +
                    '<div class="keywords">' + matchedHtml + (matchedHtml && missingHtml ? "<br>" : "") + missingHtml + "</div>" +
                    '<div class="actions">' +
                    '<a class="btn-apply" href="' + job.url + '" target="_blank">Apply</a>' +
                    appliedBtn +
                    "</div>";
                container.appendChild(card);
            });
        }

        // Mark a job as applied via the API
        async function markApplied(jobId, btn) {
            await fetch("/api/jobs/" + jobId + "/apply", { method: "POST" });
            btn.outerHTML = '<span class="btn-applied">Applied</span>';
        }

        // Load jobs on page load
        loadJobs();
    </script>
</body>
</html>

What's new in this template?

This updated template adds three major sections to your dashboard:

  • A search form that POSTs a job role and location to /search. The button disables itself and shows a loading message on submit so the user knows scoring is in progress.
  • A job card grid that fetches results from /api/jobs on page load. Each card shows the job title, company, location, AI score, ATS score, keyword tags, and action buttons.
  • Color-coded score pills that turn green (60+), yellow (40-60), or red (below 40) so you can spot strong matches at a glance.
  • Save templates/dashboard.html.

Seeing a Jinja2 error on refresh?

  • Make sure all {{ }} blocks use double curly braces. A single brace causes a template syntax error.
  • Check that the {% with %} block for flash messages has a matching {% endwith %} and {% endfor %}.
  • The search form won't work yet because the /search route doesn't exist. You'll add it in the next substep.

Still stuck? Ask for help.

Create the /search route

The search form sends a POST request to /search, but that route doesn't exist yet. You need to wire up the backend that receives the form data, calls the Adzuna API for real job listings, and scores each one with Ollama.

First, you'll add a few new imports and configuration values to the top of your app. Then you'll add a placeholder for the ATS scoring function that you'll build in the next step.

  • Open app.py in your code editor.
  • Find the import section at the very top of the file. Add these three new imports alongside your existing ones:
import json
import requests
from ollama import chat
  • Find the line that sets DATABASE. Add these three constants directly below it:
# Adzuna API credentials (loaded from environment variables)
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "your-app-id-here")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "your-app-key-here")
OLLAMA_MODEL = "llama3"

What do these constants do?

  • json lets you convert Python lists to JSON strings (for storing keyword arrays in SQLite) and back.
  • requests makes HTTP calls to the Adzuna API to fetch job listings.
  • from ollama import chat gives you the same Ollama chat function you used in your CLI project for AI scoring.
  • ADZUNA_APP_ID and ADZUNA_APP_KEY pull your API credentials from environment variables. The fallback strings are just placeholders.
  • Add this placeholder function below your constants and above the login_manager setup. You'll replace it with a real TF-IDF scorer in the next step:
# Placeholder ATS scorer - replaced with real TF-IDF scoring in next step
def calculate_ats_score(resume_text, job_description):
    return {"ats_score": 0.0, "matched_keywords": [], "missing_keywords": []}

Why a placeholder?

The /search route you're about to write calls calculate_ats_score() for every job. You haven't built the real scorer yet, so this placeholder returns zeroes. That way the route works end-to-end right now, and you'll swap in real keyword scoring in the next step.

Now for the main event. The /search route handles the entire job search pipeline: read the form, call Adzuna, score with Ollama, score with ATS, and store results.

  • In app.py, find the /upload route function. Add the following /search route below it:
# --- Job search ---

@app.route("/search", methods=["POST"])
@login_required
def search():
    role = request.form.get("role", "").strip()
    location = request.form.get("location", "").strip()
    if not role:
        flash("Please enter a job role.", "error")
        return redirect(url_for("dashboard"))
    conn = get_db()
    user_row = conn.execute(
        "SELECT resume_text FROM users WHERE id = ?",
        (current_user.id,)
    ).fetchone()
    resume_text = user_row["resume_text"] if user_row else ""
    if not resume_text:
        flash("Please upload your resume first.", "error")
        conn.close()
        return redirect(url_for("upload"))
    # Call the Adzuna API for job listings
    try:
        api_url = (
            f"https://api.adzuna.com/v1/api/jobs/in/search/1"
            f"?app_id={ADZUNA_APP_ID}"
            f"&app_key={ADZUNA_APP_KEY}"
            f"&results_per_page=10"
            f"&what={requests.utils.quote(role)}"
        )
        if location:
            api_url += f"&where={requests.utils.quote(location)}"
        response = requests.get(api_url, timeout=15)
        response.raise_for_status()
        data = response.json()
    except Exception:
        flash("Error fetching jobs from Adzuna. Please try again.", "error")
        conn.close()
        return redirect(url_for("dashboard"))
    results = data.get("results", [])
    for job in results:
        adzuna_id = str(job.get("id", ""))
        title = job.get("title", "")
        company = job.get("company", {}).get("display_name", "")
        job_location = job.get("location", {}).get("display_name", "")
        description = job.get("description", "")
        salary_min = job.get("salary_min")
        salary_max = job.get("salary_max")
        url = job.get("redirect_url", "")
        # Score the job with Ollama AI
        ai_score = 0.0
        try:
            prompt = (
                f"Rate how well this resume matches the following job on a scale of 0-100. "
                f"Reply with ONLY a number.\n\n"
                f"Resume:\n{resume_text[:2000]}\n\n"
                f"Job Title: {title}\n"
                f"Job Description: {description}"
            )
            ollama_response = chat(
                model=OLLAMA_MODEL,
                messages=[{"role": "user", "content": prompt}]
            )
            score_text = ollama_response.message.content.strip()
            for word in score_text.split():
                try:
                    ai_score = float(word)
                    if 0 <= ai_score <= 100:
                        break
                except ValueError:
                    continue
        except Exception:
            ai_score = 0.0
        # ATS keyword score (placeholder for now)
        ats_result = calculate_ats_score(resume_text, description)
        ats_score = ats_result["ats_score"]
        matched_kw = json.dumps(ats_result["matched_keywords"])
        missing_kw = json.dumps(ats_result["missing_keywords"])
        # Insert into the jobs table, skip duplicates
        try:
            conn.execute(
                """INSERT OR IGNORE INTO jobs
                   (user_id, adzuna_id, title, company, location, description,
                    salary_min, salary_max, url, ai_score, ats_score,
                    matched_keywords, missing_keywords)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (current_user.id, adzuna_id, title, company, job_location,
                 description, salary_min, salary_max, url, ai_score,
                 ats_score, matched_kw, missing_kw)
            )
        except Exception:
            pass
    conn.commit()
    conn.close()
    flash(f"Found and scored {len(results)} jobs!", "success")
    return redirect(url_for("dashboard"))

What does this route do?

This single route handles the full search pipeline in five stages:

  • Validation checks that a role was entered and a resume exists. If not, it redirects with a flash message.
  • Adzuna API call fetches up to 10 job listings from India (country code in). The what parameter carries the role and where carries the location.
  • Ollama AI scoring sends a prompt asking the model to rate the resume-to-job match on a 0-100 scale. The code parses the first valid number from the response.
  • ATS scoring calls the placeholder function for now. It returns 0 until you build the real scorer.
  • INSERT OR IGNORE stores each job in the database. The UNIQUE(user_id, adzuna_id) constraint prevents duplicate entries if you search the same role twice.
  • Save app.py.

Seeing an import error when the server restarts?

  • Make sure import json and import requests are at the very top of the file with your other imports.
  • If you see ModuleNotFoundError: No module named 'requests', install it by running pip install requests in your terminal.
  • Check that the placeholder calculate_ats_score function is defined above any route that calls it. Python reads top to bottom.

Help me debug my import errors in app.py.

Update the jobs API and test the full flow

Your /api/jobs endpoint currently returns all jobs in the database. Now that multiple users can register and search, each user should only see their own results. You also need a small helper route to let the dashboard mark jobs as applied.

  • In app.py, find your existing /api/jobs route function. Replace the entire function with this updated version that filters by the current user and includes all score fields:
# --- API endpoint for dashboard JS ---

@app.route("/api/jobs")
@login_required
def api_jobs():
    conn = get_db()
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs WHERE user_id = ? ORDER BY (ai_score + ats_score) DESC""",
        (current_user.id,)
    ).fetchall()
    conn.close()
    jobs = []
    for row in rows:
        jobs.append({
            "id": row["id"],
            "adzuna_id": row["adzuna_id"],
            "title": row["title"],
            "company": row["company"],
            "location": row["location"],
            "description": row["description"],
            "salary_min": row["salary_min"],
            "salary_max": row["salary_max"],
            "url": row["url"],
            "ai_score": row["ai_score"],
            "ats_score": row["ats_score"],
            "matched_keywords": json.loads(row["matched_keywords"]),
            "missing_keywords": json.loads(row["missing_keywords"]),
            "applied": row["applied"]
        })
    return jsonify(jobs)

What changed in this endpoint?

  • WHERE user_id = ? filters results so each user only sees their own jobs.
  • ORDER BY (ai_score + ats_score) DESC sorts jobs by the combined score. The best matches appear first.
  • json.loads() converts the stored keyword JSON strings back into Python lists so the dashboard JavaScript receives proper arrays.
  • Add this small route right below api_jobs(). It lets the dashboard's "Mark Applied" button update a job's status:
# --- Mark as applied ---

@app.route("/api/jobs/<int:job_id>/apply", methods=["POST"])
@login_required
def mark_applied(job_id):
    conn = get_db()
    conn.execute(
        "UPDATE jobs SET applied = 1 WHERE id = ? AND user_id = ?",
        (job_id, current_user.id)
    )
    conn.commit()
    conn.close()
    return jsonify({"status": "ok"})

What does this route do?

When a user clicks "Mark Applied" on a job card, the JavaScript sends a POST request to /api/jobs/<id>/apply. The route sets the applied column to 1 for that job. The AND user_id = ? check ensures a user can only mark their own jobs.

  • Save app.py.

Time to test the full search flow from end to end.

  • Make sure Ollama is running locally. If it's not, start it by running:
ollama serve
  • Set your Adzuna API credentials as environment variables before starting the server. Run these commands (replacing with your actual keys):
export ADZUNA_APP_ID=your-app-id-here
export ADZUNA_APP_KEY=your-app-key-here
  • Start your Flask dev server by running:
flask --app app run --debug
  • Open http://127.0.0.1:5000 in your browser and log in.
  • In the Job role field, type IT Associate.
  • In the Location field, type Gurugram.
  • Click Search Jobs.

The button should change to "Searching..." and the loading message appears. After about a minute (Ollama scores each of the 10 jobs one at a time), the page redirects back to the dashboard.

You should see job cards appear in a grid. Each card shows a title, company, location, an AI score pill, and an ATS score pill (showing 0 for now since the placeholder is in use).

No job cards appearing after search?

  • Check the terminal for error messages. If you see Error fetching jobs from Adzuna, your API credentials may be wrong. Double check your ADZUNA_APP_ID and ADZUNA_APP_KEY environment variables.
  • If the flash message says "Please upload your resume first", go to the upload page and re-upload your PDF.
  • If the page loads but the card area is empty, open your browser's developer console (press F12) and check for JavaScript errors. A typo in the loadJobs() function would show up there.
  • Make sure Ollama is running. If you see connection errors in the terminal, start Ollama with ollama serve in a separate terminal window.

Help me debug my job search results.

✔️ Awesome, I've got everything!

Double check you've saved both app.py and templates/dashboard.html.

ⓧ I'd like to double check the full code

Here is the full app.py as it should look at the end of this step. Note that calculate_ats_score is a temporary placeholder function defined inline. You'll replace it with a real import in the next step.

import os
import json
import sqlite3
import secrets
import requests
import bcrypt
from flask import (
    Flask, render_template, request, redirect,
    url_for, flash, jsonify, session, make_response
)
from flask_login import (
    LoginManager, UserMixin, login_user,
    logout_user, login_required, current_user
)
from werkzeug.utils import secure_filename
from pypdf import PdfReader
from ollama import chat

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024  # 5 MB limit

DATABASE = os.path.join(os.path.dirname(__file__), "jobs.db")
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "your-app-id-here")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "your-app-key-here")
OLLAMA_MODEL = "llama3"

# Placeholder ATS scorer - replaced with real TF-IDF scoring in next step
def calculate_ats_score(resume_text, job_description):
    return {"ats_score": 0.0, "matched_keywords": [], "missing_keywords": []}

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"


# --- Database helpers ---

def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            resume_text TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            adzuna_id TEXT NOT NULL,
            title TEXT,
            company TEXT,
            location TEXT,
            description TEXT,
            salary_min REAL,
            salary_max REAL,
            url TEXT,
            ai_score REAL DEFAULT 0,
            ats_score REAL DEFAULT 0,
            matched_keywords TEXT DEFAULT '[]',
            missing_keywords TEXT DEFAULT '[]',
            applied INTEGER DEFAULT 0,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            UNIQUE(user_id, adzuna_id)
        )
    """)
    conn.commit()
    conn.close()


# --- User model for Flask-Login ---

class User(UserMixin):
    def __init__(self, id, username, resume_text=None):
        self.id = id
        self.username = username
        self.resume_text = resume_text


@login_manager.user_loader
def load_user(user_id):
    conn = get_db()
    row = conn.execute(
        "SELECT id, username, resume_text FROM users WHERE id = ?",
        (user_id,)
    ).fetchone()
    conn.close()
    if row:
        return User(row["id"], row["username"], row["resume_text"])
    return None


# --- Auth routes ---

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        if not username or not password:
            flash("Username and password are required.", "error")
            return redirect(url_for("register"))
        hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
        conn = get_db()
        try:
            conn.execute(
                "INSERT INTO users (username, password_hash) VALUES (?, ?)",
                (username, hashed.decode("utf-8"))
            )
            conn.commit()
        except sqlite3.IntegrityError:
            flash("Username already taken.", "error")
            conn.close()
            return redirect(url_for("register"))
        conn.close()
        flash("Account created! Please log in.", "success")
        return redirect(url_for("login"))
    return render_template("register.html")


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        conn = get_db()
        row = conn.execute(
            "SELECT id, username, password_hash, resume_text FROM users WHERE username = ?",
            (username,)
        ).fetchone()
        conn.close()
        if row and bcrypt.checkpw(password.encode("utf-8"), row["password_hash"].encode("utf-8")):
            user = User(row["id"], row["username"], row["resume_text"])
            login_user(user)
            return redirect(url_for("dashboard"))
        flash("Invalid username or password.", "error")
        return redirect(url_for("login"))
    return render_template("login.html")


@app.route("/logout")
@login_required
def logout():
    logout_user()
    flash("You have been logged out.", "success")
    return redirect(url_for("login"))


# --- Dashboard ---

@app.route("/")
@login_required
def dashboard():
    has_resume = current_user.resume_text is not None and len(current_user.resume_text) > 0
    return render_template("dashboard.html", has_resume=has_resume, username=current_user.username)


# --- Resume upload ---

@app.route("/upload", methods=["GET", "POST"])
@login_required
def upload():
    if request.method == "POST":
        if "resume" not in request.files:
            flash("No file selected.", "error")
            return redirect(url_for("upload"))
        file = request.files["resume"]
        if file.filename == "":
            flash("No file selected.", "error")
            return redirect(url_for("upload"))
        if not file.filename.lower().endswith(".pdf"):
            flash("Only PDF files are accepted.", "error")
            return redirect(url_for("upload"))
        filename = secure_filename(file.filename)
        filepath = os.path.join(app.config["UPLOAD_FOLDER"], f"{current_user.id}_{filename}")
        file.save(filepath)
        try:
            reader = PdfReader(filepath)
            text = ""
            for page in reader.pages:
                page_text = page.extract_text()
                if page_text:
                    text += page_text + "\n"
        except Exception:
            flash("Could not read the PDF. Please try a different file.", "error")
            return redirect(url_for("upload"))
        if not text.strip():
            flash("No text found in PDF. Make sure it is not a scanned image.", "error")
            return redirect(url_for("upload"))
        conn = get_db()
        conn.execute(
            "UPDATE users SET resume_text = ? WHERE id = ?",
            (text.strip(), current_user.id)
        )
        conn.commit()
        conn.close()
        flash("Resume uploaded and parsed successfully!", "success")
        return redirect(url_for("dashboard"))
    return render_template("upload.html")


# --- Job search ---

@app.route("/search", methods=["POST"])
@login_required
def search():
    role = request.form.get("role", "").strip()
    location = request.form.get("location", "").strip()
    if not role:
        flash("Please enter a job role.", "error")
        return redirect(url_for("dashboard"))
    conn = get_db()
    user_row = conn.execute(
        "SELECT resume_text FROM users WHERE id = ?",
        (current_user.id,)
    ).fetchone()
    resume_text = user_row["resume_text"] if user_row else ""
    if not resume_text:
        flash("Please upload your resume first.", "error")
        conn.close()
        return redirect(url_for("upload"))
    # Call the Adzuna API for job listings
    try:
        api_url = (
            f"https://api.adzuna.com/v1/api/jobs/in/search/1"
            f"?app_id={ADZUNA_APP_ID}"
            f"&app_key={ADZUNA_APP_KEY}"
            f"&results_per_page=10"
            f"&what={requests.utils.quote(role)}"
        )
        if location:
            api_url += f"&where={requests.utils.quote(location)}"
        response = requests.get(api_url, timeout=15)
        response.raise_for_status()
        data = response.json()
    except Exception:
        flash("Error fetching jobs from Adzuna. Please try again.", "error")
        conn.close()
        return redirect(url_for("dashboard"))
    results = data.get("results", [])
    for job in results:
        adzuna_id = str(job.get("id", ""))
        title = job.get("title", "")
        company = job.get("company", {}).get("display_name", "")
        job_location = job.get("location", {}).get("display_name", "")
        description = job.get("description", "")
        salary_min = job.get("salary_min")
        salary_max = job.get("salary_max")
        url = job.get("redirect_url", "")
        # Score the job with Ollama AI
        ai_score = 0.0
        try:
            prompt = (
                f"Rate how well this resume matches the following job on a scale of 0-100. "
                f"Reply with ONLY a number.\n\n"
                f"Resume:\n{resume_text[:2000]}\n\n"
                f"Job Title: {title}\n"
                f"Job Description: {description}"
            )
            ollama_response = chat(
                model=OLLAMA_MODEL,
                messages=[{"role": "user", "content": prompt}]
            )
            score_text = ollama_response.message.content.strip()
            for word in score_text.split():
                try:
                    ai_score = float(word)
                    if 0 <= ai_score <= 100:
                        break
                except ValueError:
                    continue
        except Exception:
            ai_score = 0.0
        # ATS keyword score (placeholder for now)
        ats_result = calculate_ats_score(resume_text, description)
        ats_score = ats_result["ats_score"]
        matched_kw = json.dumps(ats_result["matched_keywords"])
        missing_kw = json.dumps(ats_result["missing_keywords"])
        # Insert into the jobs table, skip duplicates
        try:
            conn.execute(
                """INSERT OR IGNORE INTO jobs
                   (user_id, adzuna_id, title, company, location, description,
                    salary_min, salary_max, url, ai_score, ats_score,
                    matched_keywords, missing_keywords)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (current_user.id, adzuna_id, title, company, job_location,
                 description, salary_min, salary_max, url, ai_score,
                 ats_score, matched_kw, missing_kw)
            )
        except Exception:
            pass
    conn.commit()
    conn.close()
    flash(f"Found and scored {len(results)} jobs!", "success")
    return redirect(url_for("dashboard"))


# --- API endpoint for dashboard JS ---

@app.route("/api/jobs")
@login_required
def api_jobs():
    conn = get_db()
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs WHERE user_id = ? ORDER BY (ai_score + ats_score) DESC""",
        (current_user.id,)
    ).fetchall()
    conn.close()
    jobs = []
    for row in rows:
        jobs.append({
            "id": row["id"],
            "adzuna_id": row["adzuna_id"],
            "title": row["title"],
            "company": row["company"],
            "location": row["location"],
            "description": row["description"],
            "salary_min": row["salary_min"],
            "salary_max": row["salary_max"],
            "url": row["url"],
            "ai_score": row["ai_score"],
            "ats_score": row["ats_score"],
            "matched_keywords": json.loads(row["matched_keywords"]),
            "missing_keywords": json.loads(row["missing_keywords"]),
            "applied": row["applied"]
        })
    return jsonify(jobs)


# --- Mark as applied ---

@app.route("/api/jobs/<int:job_id>/apply", methods=["POST"])
@login_required
def mark_applied(job_id):
    conn = get_db()
    conn.execute(
        "UPDATE jobs SET applied = 1 WHERE id = ? AND user_id = ?",
        (job_id, current_user.id)
    )
    conn.commit()
    conn.close()
    return jsonify({"status": "ok"})


# --- Init and run ---

if __name__ == "__main__":
    init_db()
    app.run(debug=True)

And here is the full templates/dashboard.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Job Matcher Dashboard</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
    <style>
        .top-bar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: #1e293b; border-bottom: 1px solid #334155; }
        .top-bar .user-info { display: flex; align-items: center; gap: 1rem; }
        .top-bar .user-info span { color: #94a3b8; }
        .top-bar a { color: #38bdf8; text-decoration: none; font-size: 0.9rem; }
        .resume-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; }
        .resume-badge.uploaded { background: #14532d; color: #86efac; }
        .resume-badge.missing { background: #7f1d1d; color: #fca5a5; }
        .search-form { display: flex; gap: 0.75rem; padding: 1rem 2rem; background: #1e293b; border-bottom: 1px solid #334155; flex-wrap: wrap; align-items: center; }
        .search-form input { padding: 0.6rem 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #e2e8f0; font-size: 0.95rem; }
        .search-form button { padding: 0.6rem 1.5rem; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 0.95rem; }
        .search-form button:hover { background: #1d4ed8; }
        .flash-area { padding: 0 2rem; }
        .flash { padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.9rem; }
        .flash.error { background: #7f1d1d; color: #fca5a5; }
        .flash.success { background: #14532d; color: #86efac; }
        #jobs-container { padding: 1.5rem 2rem; display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; }
        .job-card { background: #1e293b; border-radius: 12px; padding: 1.25rem; border: 1px solid #334155; }
        .job-card h3 { color: #f1f5f9; margin-bottom: 0.25rem; font-size: 1.05rem; }
        .job-card .company { color: #94a3b8; font-size: 0.9rem; margin-bottom: 0.75rem; }
        .scores { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
        .score-pill { padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
        .score-green { background: #14532d; color: #86efac; }
        .score-yellow { background: #713f12; color: #fde68a; }
        .score-red { background: #7f1d1d; color: #fca5a5; }
        .keywords { font-size: 0.8rem; margin-bottom: 0.75rem; }
        .keywords .matched { color: #86efac; }
        .keywords .missing { color: #fca5a5; }
        .job-card .actions { display: flex; gap: 0.5rem; }
        .job-card .actions a, .job-card .actions button { padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 0.85rem; text-decoration: none; cursor: pointer; border: none; }
        .btn-apply { background: #2563eb; color: white; }
        .btn-applied { background: #334155; color: #94a3b8; }
        .btn-mark { background: #0f172a; color: #38bdf8; border: 1px solid #334155 !important; }
        .loading { text-align: center; padding: 3rem; color: #94a3b8; display: none; }
    </style>
</head>
<body style="background: #0f172a; color: #e2e8f0; font-family: 'Segoe UI', sans-serif; margin: 0;">
    <div class="top-bar">
        <h2 style="color: #38bdf8;">Job Matcher</h2>
        <div class="user-info">
            <span>Hello, {{ username }}</span>
            {% if has_resume %}
            <span class="resume-badge uploaded">Resume Uploaded</span>
            {% else %}
            <a href="{{ url_for('upload') }}"><span class="resume-badge missing">Upload Resume</span></a>
            {% endif %}
            <a href="{{ url_for('upload') }}">Upload Resume</a>
            <a href="{{ url_for('logout') }}">Logout</a>
        </div>
    </div>

    <form class="search-form" method="POST" action="{{ url_for('search') }}" id="searchForm">
        <input type="text" name="role" placeholder="Job role (e.g. IT Associate)" required>
        <input type="text" name="location" placeholder="Location (e.g. Gurugram)">
        <button type="submit" id="searchBtn">Search Jobs</button>
    </form>

    <div class="flash-area">
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="flash {{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}
    </div>

    <div class="loading" id="loading">Searching and scoring jobs... this may take a minute.</div>
    <div id="jobs-container"></div>

    <script>
        document.getElementById("searchForm").addEventListener("submit", function () {
            document.getElementById("loading").style.display = "block";
            document.getElementById("searchBtn").disabled = true;
            document.getElementById("searchBtn").textContent = "Searching...";
        });

        async function loadJobs() {
            const res = await fetch("/api/jobs");
            const jobs = await res.json();
            const container = document.getElementById("jobs-container");
            container.innerHTML = "";

            if (jobs.length === 0) {
                container.innerHTML = '<p style="padding: 2rem; color: #94a3b8;">No jobs found yet. Use the search bar above to find jobs.</p>';
                return;
            }

            jobs.forEach(function (job) {
                const aiColor = job.ai_score >= 60 ? "score-green" : job.ai_score >= 40 ? "score-yellow" : "score-red";
                const atsColor = job.ats_score >= 60 ? "score-green" : job.ats_score >= 40 ? "score-yellow" : "score-red";

                const matchedHtml = job.matched_keywords.length > 0
                    ? '<span class="matched">Matched: ' + job.matched_keywords.join(", ") + "</span>"
                    : "";
                const missingHtml = job.missing_keywords.length > 0
                    ? '<span class="missing">Missing: ' + job.missing_keywords.join(", ") + "</span>"
                    : "";

                const appliedBtn = job.applied
                    ? '<span class="btn-applied">Applied</span>'
                    : '<button class="btn-mark" onclick="markApplied(' + job.id + ', this)">Mark Applied</button>';

                const card = document.createElement("div");
                card.className = "job-card";
                card.innerHTML =
                    "<h3>" + job.title + "</h3>" +
                    '<div class="company">' + job.company + " &middot; " + job.location + "</div>" +
                    '<div class="scores">' +
                    '<span class="score-pill ' + aiColor + '">AI: ' + job.ai_score + "</span>" +
                    '<span class="score-pill ' + atsColor + '">ATS: ' + job.ats_score + "</span>" +
                    "</div>" +
                    '<div class="keywords">' + matchedHtml + (matchedHtml && missingHtml ? "<br>" : "") + missingHtml + "</div>" +
                    '<div class="actions">' +
                    '<a class="btn-apply" href="' + job.url + '" target="_blank">Apply</a>' +
                    appliedBtn +
                    "</div>";
                container.appendChild(card);
            });
        }

        async function markApplied(jobId, btn) {
            await fetch("/api/jobs/" + jobId + "/apply", { method: "POST" });
            btn.outerHTML = '<span class="btn-applied">Applied</span>';
        }

        loadJobs();
    </script>
</body>
</html>

Your web-based job search is working end to end. You can search, score, and browse results all from the browser. The one thing missing is real ATS keyword scoring. In the next step, you'll build a TF-IDF cosine similarity scorer that fills in those empty ATS pills with actual keyword match data.

Add ATS Keyword Scoring with TF-IDF

Your search pipeline already scores each job with Ollama's AI analysis. That gives you a semantic understanding of how well your resume matches a role.

Real-world ATS systems work differently. They scan for specific keywords, not meaning. In this step, you will build a second scoring engine that uses TF-IDF cosine similarity to measure keyword overlap between your resume and each job description.

In this step, get ready to:
  • Create an ATS scoring module that computes TF-IDF cosine similarity.
  • Integrate the scorer into your Flask search pipeline.
  • Verify dual scores and keyword analysis display on your dashboard.
Create the ATS scoring module

TF-IDF stands for Term Frequency-Inverse Document Frequency. It measures how important each word is in a document relative to a collection. Cosine similarity then compares the two TF-IDF vectors to produce a score from 0 (no overlap) to 1 (identical keywords).

Why TF-IDF alongside Ollama?

Your Ollama AI score captures semantic meaning. It understands that "managed cloud infrastructure" is relevant to a "DevOps Engineer" role even if those exact words do not appear in your resume.

Real ATS systems do not reason about meaning. They scan for exact keyword matches. TF-IDF mimics this keyword-matching behavior and tells you which specific terms you are missing. Together, the two scores give you both the big picture and the granular keyword gaps.

  • Create a new file called ats_scorer.py in your project root (the same folder where app.py lives).
  • Paste the following code into ats_scorer.py:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


def calculate_ats_score(resume_text, job_description):
    """
    Calculate ATS compatibility score between resume and job description
    using TF-IDF cosine similarity.

    Returns dict with ats_score (0-100), matched_keywords, missing_keywords.
    """
    # Return zeros if either input is empty
    if not resume_text or not job_description:
        return {
            "ats_score": 0.0,
            "matched_keywords": [],
            "missing_keywords": []
        }

    # Vectorize both texts using TF-IDF
    documents = [resume_text, job_description]
    vectorizer = TfidfVectorizer(stop_words="english", max_features=200)
    tfidf_matrix = vectorizer.fit_transform(documents)

    # Calculate cosine similarity and convert to 0-100 scale
    similarity = cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:2])
    score = round(float(similarity[0][0]) * 100, 1)

    # Extract feature names and per-document vectors
    feature_names = vectorizer.get_feature_names_out()
    resume_vector = tfidf_matrix[0].toarray()[0]
    job_vector = tfidf_matrix[1].toarray()[0]

    # Find the top 20 keywords from the job description by TF-IDF weight
    job_top_indices = job_vector.argsort()[-20:][::-1]
    job_keywords = [feature_names[i] for i in job_top_indices if job_vector[i] > 0]

    # Split into matched (in resume) and missing (not in resume)
    matched = [kw for kw in job_keywords if resume_vector[feature_names.tolist().index(kw)] > 0]
    missing = [kw for kw in job_keywords if resume_vector[feature_names.tolist().index(kw)] == 0]

    return {
        "ats_score": score,
        "matched_keywords": matched[:10],
        "missing_keywords": missing[:10]
    }

What does this code do?

  • TfidfVectorizer(stop_words="english", max_features=200) converts both your resume and the job description into numerical vectors. It filters out common English words like "the" and "is" and keeps the 200 most important terms.
  • cosine_similarity() measures how similar the two vectors are. A value of 1.0 means identical keyword profiles. A value of 0.0 means no overlap at all.
  • The keyword extraction section identifies the top 20 terms the job cares about, then checks which ones your resume already mentions (matched) and which ones it is missing.
  • Save ats_scorer.py.
  • Verify the module loads by running this quick test in your terminal:
python -c "from ats_scorer import calculate_ats_score; print(calculate_ats_score('python flask sql', 'python developer with flask experience'))"

You should see a dictionary printed with an ats_score value, a matched_keywords list, and a missing_keywords list.

Seeing a ModuleNotFoundError?

  • If the error mentions sklearn, make sure you installed scikit-learn earlier with pip install scikit-learn==1.8.0.
  • If the error mentions ats_scorer, double check that ats_scorer.py is saved in the same folder as app.py.
  • help me debug this ModuleNotFoundError when importing ats_scorer
Wire the scorer into your search route

The scoring module is ready. Now you need to call it from your /search route so every job gets an ATS score alongside its AI score.

  • Open app.py.
  • At the top of the file, find your other import lines.
  • Make sure this import line is present:
from ats_scorer import calculate_ats_score

Next, find the section inside your /search route where each job is processed in the for job in results: loop. After the Ollama AI scoring block, add the ATS scoring call and store the results as JSON strings:

  • Find the line where the Ollama except Exception: block ends with ai_score = 0.0.
  • Directly below it, add this code to call the ATS scorer:
        # ATS keyword score
        ats_result = calculate_ats_score(resume_text, description)
        ats_score = ats_result["ats_score"]
        matched_kw = json.dumps(ats_result["matched_keywords"])
        missing_kw = json.dumps(ats_result["missing_keywords"])

What does this code do?

  • calculate_ats_score() takes the user's resume text and the current job's description, then returns the ATS score plus matched and missing keyword lists.
  • json.dumps() converts the Python lists into JSON strings so they can be stored in a single SQLite text column.
  • Verify that the INSERT OR IGNORE statement below the ATS scoring block includes all three new values. It should look like this:
        try:
            conn.execute(
                """INSERT OR IGNORE INTO jobs
                   (user_id, adzuna_id, title, company, location, description,
                    salary_min, salary_max, url, ai_score, ats_score,
                    matched_keywords, missing_keywords)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (current_user.id, adzuna_id, title, company, job_location,
                 description, salary_min, salary_max, url, ai_score,
                 ats_score, matched_kw, missing_kw)
            )
        except Exception:
            pass

What does this code do?

This inserts each job into the jobs table with both the AI score and the ATS score. INSERT OR IGNORE skips duplicates based on the UNIQUE(user_id, adzuna_id) constraint, so re-searching the same role will not create duplicate rows.

Your /search route now runs both scoring pipelines for every job it fetches.

  • Save app.py.

Seeing an ImportError when starting the server?

  • Make sure ats_scorer.py is saved in the same directory as app.py. Python looks for modules relative to the file that imports them.
  • Check that the filename is exactly ats_scorer.py with an underscore, not a hyphen.
  • help me fix this ImportError for ats_scorer in my Flask app
Verify dual scores on your dashboard

The scoring is hooked up. Now you need to confirm that the /api/jobs endpoint sends both scores to the browser, and that the dashboard JavaScript renders them with color coding and keyword analysis.

  • In app.py, find the api_jobs() function. Verify that the JSON response includes ats_score, matched_keywords, and missing_keywords fields. The keywords should be parsed from JSON strings back into lists:
@app.route("/api/jobs")
@login_required
def api_jobs():
    conn = get_db()
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs WHERE user_id = ? ORDER BY (ai_score + ats_score) DESC""",
        (current_user.id,)
    ).fetchall()
    conn.close()
    jobs = []
    for row in rows:
        jobs.append({
            "id": row["id"],
            "adzuna_id": row["adzuna_id"],
            "title": row["title"],
            "company": row["company"],
            "location": row["location"],
            "description": row["description"],
            "salary_min": row["salary_min"],
            "salary_max": row["salary_max"],
            "url": row["url"],
            "ai_score": row["ai_score"],
            "ats_score": row["ats_score"],
            # Parse JSON strings back into Python lists
            "matched_keywords": json.loads(row["matched_keywords"]),
            "missing_keywords": json.loads(row["missing_keywords"]),
            "applied": row["applied"]
        })
    return jsonify(jobs)

What does this code do?

  • The query sorts results by the sum of both scores, so the best overall matches appear first.
  • json.loads() converts the JSON strings stored in SQLite back into Python lists. The dashboard JavaScript receives them as arrays it can render directly.
  • Also verify that the mark-applied route exists at the bottom of app.py, just above the if __name__ == "__main__": block. If it is missing, add it:
@app.route("/api/jobs/<int:job_id>/apply", methods=["POST"])
@login_required
def mark_applied(job_id):
    # Update the applied flag for this job
    conn = get_db()
    conn.execute(
        "UPDATE jobs SET applied = 1 WHERE id = ? AND user_id = ?",
        (job_id, current_user.id)
    )
    conn.commit()
    conn.close()
    return jsonify({"status": "ok"})

What does this code do?

This route lets the dashboard JavaScript mark a job as "applied" without refreshing the page. The AND user_id = ? check ensures users can only update their own jobs.

  • Save app.py.
  • Start (or restart) your Flask server by running:
flask --app app run --debug
  • Open http://127.0.0.1:5000 in your browser.
  • Log in, make sure your resume is uploaded, then search for a job role.
  • After the search completes, you should see job cards with two colored score pills: one for AI and one for ATS. Green means 60 or above, yellow means 40 to 59, and red means below 40.
  • Each card should also show matched keywords in green and missing keywords in red below the score pills.

Scores not showing on your dashboard?

  • Open your browser's DevTools (press F12) and check the Console tab for JavaScript errors.
  • Visit http://127.0.0.1:5000/api/jobs directly in your browser to verify the JSON response includes ats_score and matched_keywords fields.
  • If you see old jobs without ATS scores, they were stored before you added the scorer. Delete your jobs.db file and search again to regenerate scores.
  • help me debug why my ATS scores are not appearing on the dashboard

✔️ Awesome, I've got everything!

Great. Double check that you have saved both ats_scorer.py and app.py.

ⓧ I'd like to double check the full code

Here is the complete ats_scorer.py:

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


def calculate_ats_score(resume_text, job_description):
    """
    Calculate ATS compatibility score between resume and job description
    using TF-IDF cosine similarity.

    Returns dict with ats_score (0-100), matched_keywords, missing_keywords.
    """
    if not resume_text or not job_description:
        return {
            "ats_score": 0.0,
            "matched_keywords": [],
            "missing_keywords": []
        }

    documents = [resume_text, job_description]
    vectorizer = TfidfVectorizer(stop_words="english", max_features=200)
    tfidf_matrix = vectorizer.fit_transform(documents)

    similarity = cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:2])
    score = round(float(similarity[0][0]) * 100, 1)

    feature_names = vectorizer.get_feature_names_out()
    resume_vector = tfidf_matrix[0].toarray()[0]
    job_vector = tfidf_matrix[1].toarray()[0]

    job_top_indices = job_vector.argsort()[-20:][::-1]
    job_keywords = [feature_names[i] for i in job_top_indices if job_vector[i] > 0]

    matched = [kw for kw in job_keywords if resume_vector[feature_names.tolist().index(kw)] > 0]
    missing = [kw for kw in job_keywords if resume_vector[feature_names.tolist().index(kw)] == 0]

    return {
        "ats_score": score,
        "matched_keywords": matched[:10],
        "missing_keywords": missing[:10]
    }

And the complete app.py:

import os
import json
import sqlite3
import secrets
import requests
import bcrypt
from flask import (
    Flask, render_template, request, redirect,
    url_for, flash, jsonify, session, make_response
)
from flask_login import (
    LoginManager, UserMixin, login_user,
    logout_user, login_required, current_user
)
from werkzeug.utils import secure_filename
from pypdf import PdfReader
from ollama import chat
from ats_scorer import calculate_ats_score

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024

DATABASE = os.path.join(os.path.dirname(__file__), "jobs.db")
ADZUNA_APP_ID = os.environ.get("ADZUNA_APP_ID", "your-app-id-here")
ADZUNA_APP_KEY = os.environ.get("ADZUNA_APP_KEY", "your-app-key-here")
OLLAMA_MODEL = "llama3"

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"

def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn

def init_db():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            resume_text TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            adzuna_id TEXT NOT NULL,
            title TEXT,
            company TEXT,
            location TEXT,
            description TEXT,
            salary_min REAL,
            salary_max REAL,
            url TEXT,
            ai_score REAL DEFAULT 0,
            ats_score REAL DEFAULT 0,
            matched_keywords TEXT DEFAULT '[]',
            missing_keywords TEXT DEFAULT '[]',
            applied INTEGER DEFAULT 0,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            UNIQUE(user_id, adzuna_id)
        )
    """)
    conn.commit()
    conn.close()

class User(UserMixin):
    def __init__(self, id, username, resume_text=None):
        self.id = id
        self.username = username
        self.resume_text = resume_text

@login_manager.user_loader
def load_user(user_id):
    conn = get_db()
    row = conn.execute(
        "SELECT id, username, resume_text FROM users WHERE id = ?",
        (user_id,)
    ).fetchone()
    conn.close()
    if row:
        return User(row["id"], row["username"], row["resume_text"])
    return None

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        if not username or not password:
            flash("Username and password are required.", "error")
            return redirect(url_for("register"))
        hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
        conn = get_db()
        try:
            conn.execute(
                "INSERT INTO users (username, password_hash) VALUES (?, ?)",
                (username, hashed.decode("utf-8"))
            )
            conn.commit()
        except sqlite3.IntegrityError:
            flash("Username already taken.", "error")
            conn.close()
            return redirect(url_for("register"))
        conn.close()
        flash("Account created! Please log in.", "success")
        return redirect(url_for("login"))
    return render_template("register.html")

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        conn = get_db()
        row = conn.execute(
            "SELECT id, username, password_hash, resume_text FROM users WHERE username = ?",
            (username,)
        ).fetchone()
        conn.close()
        if row and bcrypt.checkpw(password.encode("utf-8"), row["password_hash"].encode("utf-8")):
            user = User(row["id"], row["username"], row["resume_text"])
            login_user(user)
            return redirect(url_for("dashboard"))
        flash("Invalid username or password.", "error")
        return redirect(url_for("login"))
    return render_template("login.html")

@app.route("/logout")
@login_required
def logout():
    logout_user()
    flash("You have been logged out.", "success")
    return redirect(url_for("login"))

@app.route("/")
@login_required
def dashboard():
    has_resume = current_user.resume_text is not None and len(current_user.resume_text) > 0
    return render_template("dashboard.html", has_resume=has_resume, username=current_user.username)

@app.route("/upload", methods=["GET", "POST"])
@login_required
def upload():
    if request.method == "POST":
        if "resume" not in request.files:
            flash("No file selected.", "error")
            return redirect(url_for("upload"))
        file = request.files["resume"]
        if file.filename == "":
            flash("No file selected.", "error")
            return redirect(url_for("upload"))
        if not file.filename.lower().endswith(".pdf"):
            flash("Only PDF files are accepted.", "error")
            return redirect(url_for("upload"))
        filename = secure_filename(file.filename)
        filepath = os.path.join(app.config["UPLOAD_FOLDER"], f"{current_user.id}_{filename}")
        file.save(filepath)
        try:
            reader = PdfReader(filepath)
            text = ""
            for page in reader.pages:
                page_text = page.extract_text()
                if page_text:
                    text += page_text + "\n"
        except Exception:
            flash("Could not read the PDF. Please try a different file.", "error")
            return redirect(url_for("upload"))
        if not text.strip():
            flash("No text found in PDF. Make sure it is not a scanned image.", "error")
            return redirect(url_for("upload"))
        conn = get_db()
        conn.execute(
            "UPDATE users SET resume_text = ? WHERE id = ?",
            (text.strip(), current_user.id)
        )
        conn.commit()
        conn.close()
        flash("Resume uploaded and parsed successfully!", "success")
        return redirect(url_for("dashboard"))
    return render_template("upload.html")

@app.route("/search", methods=["POST"])
@login_required
def search():
    role = request.form.get("role", "").strip()
    location = request.form.get("location", "").strip()
    if not role:
        flash("Please enter a job role.", "error")
        return redirect(url_for("dashboard"))
    conn = get_db()
    user_row = conn.execute(
        "SELECT resume_text FROM users WHERE id = ?",
        (current_user.id,)
    ).fetchone()
    resume_text = user_row["resume_text"] if user_row else ""
    if not resume_text:
        flash("Please upload your resume first.", "error")
        conn.close()
        return redirect(url_for("upload"))
    try:
        api_url = (
            f"https://api.adzuna.com/v1/api/jobs/in/search/1"
            f"?app_id={ADZUNA_APP_ID}"
            f"&app_key={ADZUNA_APP_KEY}"
            f"&results_per_page=10"
            f"&what={requests.utils.quote(role)}"
        )
        if location:
            api_url += f"&where={requests.utils.quote(location)}"
        response = requests.get(api_url, timeout=15)
        response.raise_for_status()
        data = response.json()
    except Exception:
        flash("Error fetching jobs from Adzuna. Please try again.", "error")
        conn.close()
        return redirect(url_for("dashboard"))
    results = data.get("results", [])
    for job in results:
        adzuna_id = str(job.get("id", ""))
        title = job.get("title", "")
        company = job.get("company", {}).get("display_name", "")
        job_location = job.get("location", {}).get("display_name", "")
        description = job.get("description", "")
        salary_min = job.get("salary_min")
        salary_max = job.get("salary_max")
        url = job.get("redirect_url", "")
        ai_score = 0.0
        try:
            prompt = (
                f"Rate how well this resume matches the following job on a scale of 0-100. "
                f"Reply with ONLY a number.\n\n"
                f"Resume:\n{resume_text[:2000]}\n\n"
                f"Job Title: {title}\n"
                f"Job Description: {description}"
            )
            ollama_response = chat(
                model=OLLAMA_MODEL,
                messages=[{"role": "user", "content": prompt}]
            )
            score_text = ollama_response.message.content.strip()
            for word in score_text.split():
                try:
                    ai_score = float(word)
                    if 0 <= ai_score <= 100:
                        break
                except ValueError:
                    continue
        except Exception:
            ai_score = 0.0
        ats_result = calculate_ats_score(resume_text, description)
        ats_score = ats_result["ats_score"]
        matched_kw = json.dumps(ats_result["matched_keywords"])
        missing_kw = json.dumps(ats_result["missing_keywords"])
        try:
            conn.execute(
                """INSERT OR IGNORE INTO jobs
                   (user_id, adzuna_id, title, company, location, description,
                    salary_min, salary_max, url, ai_score, ats_score,
                    matched_keywords, missing_keywords)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (current_user.id, adzuna_id, title, company, job_location,
                 description, salary_min, salary_max, url, ai_score,
                 ats_score, matched_kw, missing_kw)
            )
        except Exception:
            pass
    conn.commit()
    conn.close()
    flash(f"Found and scored {len(results)} jobs!", "success")
    return redirect(url_for("dashboard"))

@app.route("/api/jobs")
@login_required
def api_jobs():
    conn = get_db()
    rows = conn.execute(
        """SELECT id, adzuna_id, title, company, location, description,
                  salary_min, salary_max, url, ai_score, ats_score,
                  matched_keywords, missing_keywords, applied
           FROM jobs WHERE user_id = ? ORDER BY (ai_score + ats_score) DESC""",
        (current_user.id,)
    ).fetchall()
    conn.close()
    jobs = []
    for row in rows:
        jobs.append({
            "id": row["id"],
            "adzuna_id": row["adzuna_id"],
            "title": row["title"],
            "company": row["company"],
            "location": row["location"],
            "description": row["description"],
            "salary_min": row["salary_min"],
            "salary_max": row["salary_max"],
            "url": row["url"],
            "ai_score": row["ai_score"],
            "ats_score": row["ats_score"],
            "matched_keywords": json.loads(row["matched_keywords"]),
            "missing_keywords": json.loads(row["missing_keywords"]),
            "applied": row["applied"]
        })
    return jsonify(jobs)

@app.route("/api/jobs/<int:job_id>/apply", methods=["POST"])
@login_required
def mark_applied(job_id):
    conn = get_db()
    conn.execute(
        "UPDATE jobs SET applied = 1 WHERE id = ? AND user_id = ?",
        (job_id, current_user.id)
    )
    conn.commit()
    conn.close()
    return jsonify({"status": "ok"})

if __name__ == "__main__":
    init_db()
    app.run(debug=True)

Your dual-scoring pipeline is live. Every search now gives you both a semantic AI score and a keyword-based ATS score, plus the exact terms you should add to your resume. Next up, you will take on the Secret Mission and build a downloadable report of your top matches.

Secret mission

Download Report Button

Your dashboard shows scored jobs in the browser. What if you want to share your top matches with a friend or review them offline? In this mission, you will add a Download Report button that generates a standalone HTML file of your top 10 job matches with both scores, keyword analysis, and direct apply links.

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 a free API tier, so there are no ongoing costs.

Resources you used:

  • Flask web app files: app.py and ats_scorer.py.
  • HTML templates: templates/dashboard.html, templates/login.html, templates/register.html, templates/upload.html.
  • Static assets in the static/ folder (CSS and JS files moved from your original project).
  • Uploaded resumes in the uploads/ folder.
  • SQLite database jobs.db with users and jobs tables.
  • Python packages: flask, flask-login, bcrypt, pypdf, scikit-learn.
  • Ollama running locally with the llama3 model.
  • Adzuna API free tier account.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the app soon.

  • Your Flask app only runs when you start it with flask --app app run --debug, so it does not consume resources when idle.
  • Ollama continues running in the background. It uses some memory, but you can leave it if you have the resources.
  • Your SQLite database and uploaded files stay on disk and take up minimal space.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files and database stay intact for when you return.

  • Stop the Flask development server by pressing Ctrl+C in the terminal where it is running.
  • Stop Ollama by running ollama stop llama3 in your terminal. This frees up the memory the model was using.
  • Leave all project files, the jobs.db database, and the uploads/ folder in place. They are ready whenever you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your database, uploaded resumes, and application code.

  • Stop the Flask development server by pressing Ctrl+C in the terminal.
  • Delete your entire project folder. This removes app.py, ats_scorer.py, the templates/ folder, the static/ folder, the uploads/ folder, and the jobs.db database.
  • Uninstall the Python packages you added by running:
pip uninstall flask flask-login bcrypt pypdf scikit-learn -y
  • If you no longer need Ollama for other projects, stop it with ollama stop llama3 and uninstall Ollama from your system.
  • Your Adzuna API account is free. You can leave it active or delete it from the Adzuna developer dashboard.

Mission accomplished

Nice Work!

You just transformed a command-line Python script into a fully functional web application. Your job matcher now lives in the browser, complete with user accounts, resume uploads, and dual-scored results.

Here's what you accomplished:

  • Built a Flask web server with user authentication, including registration, login, password hashing with bcrypt, and session management using Flask-Login.
  • Added browser-based PDF resume upload with text extraction using pypdf, and a web-based job search form that calls the Adzuna API and scores results with Ollama.
  • Created an ATS keyword scoring engine using TF-IDF cosine similarity from scikit-learn, displaying dual scores with color coding and keyword analysis on your dashboard.
  • Secret Mission: Added a downloadable HTML report of your top 10 job matches with both scores, keyword highlights, and direct apply links.

Ready to quiz yourself?