Build an AI Code Review Tool

Build a local AI tool that compares Python code reviews and exports results.

Introduction

30 Second Summary

Every developer knows the frustration of staring at a cryptic error message. Code reviews catch bugs before they ship, but getting feedback usually means waiting for a teammate.

In this project, you will build DevLens, an AI-powered developer toolkit that runs locally on your machine. It uses Python, Flask, and the Google Gemini API to analyze your code for bugs and explain errors in plain English.

What You'll Build

DevLens is a full-stack web application with a Flask REST API backend and a vanilla HTML, CSS, and JavaScript frontend.

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

  • A Flask backend with two API endpoints (/api/review and /api/explain) that send structured prompts to the Gemini API and return parsed AI analysis.
  • A polished, tabbed frontend with a dark-theme interface, language selector, severity badges, and loading animations.
  • Crafted system prompts that use prompt engineering to produce structured, categorized AI responses with fix suggestions.
  • Secret Mission: Add a review history feature that saves past analyses to localStorage and lets you revisit them.

Are there any prerequisites?

This project assumes basic knowledge of HTML, CSS, Python, and Flask. You need a Google account to create a free Gemini API key (no credit card required). The Gemini free tier may not be available in all regions.

Set Up Your Environment

DevLens relies on a few key tools to work. Flask serves as the web framework for your API, the google-genai SDK connects you to the Gemini API, and python-dotenv keeps your API key out of your code.

In this step, you'll create a project folder, install your Python packages, grab a free Gemini API key, and confirm everything is connected.

In this step, get ready to:
  • Create a project folder and install Flask, google-genai, and python-dotenv.
  • Get a free Gemini API key from Google AI Studio.
  • Run a test script to verify your API key works.
Create your project folder and install packages

Every Python project needs its own folder and a virtual environment. A virtual environment keeps your project's packages separate from your system-wide Python installation.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir devlens
cd devlens
  • Confirm the folder exists by running:
ls

You should see the devlens folder listed in the output.

Now create a virtual environment inside your project folder. This keeps your installed packages isolated to this project.

  • Create a virtual environment by running this command:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated copy of Python that lives inside your project folder. When you install packages inside it, they only affect this project. This prevents conflicts between different projects that might need different versions of the same package.

  • Activate the virtual environment by running this command:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try the activation command again.

On macOS, make sure you're using the exact command source venv/bin/activate with source at the start.

Help me activate my Python virtual environment.

With the virtual environment active, you can now install the three packages this project needs.

  • Install Flask, google-genai, and python-dotenv by running this command:
pip install flask google-genai python-dotenv

You should see output scrolling past as pip downloads and installs each package. It finishes with a line that says Successfully installed followed by the package names.

What are these three packages?

  • flask is a lightweight web framework for Python. It handles HTTP requests and responses for your API.
  • google-genai is Google's official SDK for the Gemini API. It lets your Python code send prompts and receive AI-generated responses.
  • python-dotenv loads environment variables from a .env file into your app. This keeps secrets like API keys out of your source code.

🙋‍♀️ Seeing an installation error?

Make sure (venv) is visible in your terminal prompt before running pip install. If you install without the virtual environment active, packages go to your system Python instead.

The google-genai package requires Python 3.10 or higher. Run python3 --version to check your version. If you see a version lower than 3.10, you'll need to upgrade Python before continuing.

Help me fix my pip install error.

Get your free Gemini API key

DevLens sends code to the Gemini API for analysis. To authenticate those requests, you need an API key. Google provides this for free through Google AI Studio with no credit card required.

  • Open your browser and go to aistudio.google.com.
  • Sign in with your Google account. If this is your first time, accept the Terms of Service when prompted.
  • In the left sidebar, click Get API key.
  • Click Create API key.
  • Select Create API key in new project if prompted.
  • Copy your new API key immediately and save it somewhere safe, like a password manager or a temporary text file. You will need it in the next step.

Is the Gemini API really free?

Yes. Google AI Studio provides a permanent free tier for the Gemini API. No billing account or credit card is needed. The free tier gives you 1,500 requests per day and 10 requests per minute on Flash models, which is more than enough for this project.

Note that free-tier data may be used to improve Google's products. The paid tier does not share data. Some regions (particularly parts of Europe) may have limited access to the free tier.

Now store your key in a .env file so your app can load it securely.

  • Open VS Code. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the devlens folder on your Desktop, then click Open.
  • Create a new file by clicking the new file icon in the Explorer sidebar (or press Cmd+N on macOS, Ctrl+N on Windows).
  • Save the file as .env in the root of your devlens folder.
  • Add the following line to your .env file, replacing your-api-key-here with the API key you just copied:
GEMINI_API_KEY=your-api-key-here

Why use a .env file?

Hardcoding API keys directly into your source code is a security risk. If you ever share your code or push it to GitHub, the key would be exposed. A .env file keeps secrets separate from your code. The python-dotenv package reads this file and loads the values as environment variables when your app starts.

  • Save .env.
Verify your setup with a test script

Before building the full application, confirm that your API key and SDK are working. You'll create a small test script that sends a prompt to Gemini and prints the response.

  • Create a new file in VS Code and save it as test_setup.py in your devlens folder.
  • Add the following code to test_setup.py:
import os
from dotenv import load_dotenv
from google import genai

# Load the API key from the .env file
load_dotenv()

# Create a Gemini client using the API key
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))

# Send a test prompt to verify the connection
response = client.models.generate_content(
    model="gemini-2.5-flash",
    contents="Say hello in one sentence."
)

# Print the AI's response
print(response.text)

What does this code do?

  • load_dotenv() reads your .env file and loads GEMINI_API_KEY into the environment.
  • genai.Client() creates a client object that handles communication with the Gemini API.
  • client.models.generate_content() sends a prompt to the gemini-2.5-flash model and returns the AI's response.
  • response.text contains the AI-generated text, which gets printed to the terminal.
  • Save test_setup.py.
  • Switch back to your terminal (make sure you still see (venv) in the prompt) and run the test script:
python3 test_setup.py

You should see a short greeting from the AI printed in your terminal. The exact wording will vary each time.

Seeing an error instead of a greeting?

  • If you see a message about an invalid API key, double check that your .env file has the correct key with no extra spaces or quotes around the value.
  • If you see ModuleNotFoundError, make sure (venv) appears in your terminal prompt. If not, re-activate the virtual environment and run the script again.
  • If you see a connection error, check your internet connection. The script needs to reach Google's servers.

Help me fix my Gemini API test script error.

✔️ Awesome, I've got everything!

Great. Double check that you've saved your .env file.

ⓧ I'd like to double check the full code

Here is what your .env file should look like (with your own API key in place of the placeholder):

GEMINI_API_KEY=your-api-key-here

Your environment is ready. You have your packages installed, your API key secured, and a confirmed connection to Gemini. Next up, you'll build the Flask API that powers DevLens.

Build the Flask API

Your environment is set up with Flask, the Gemini SDK, and a working API key. Now it's time to build the backend that powers DevLens.

You'll create a Flask API with two endpoints. One reviews code for bugs and security issues. The other explains error messages in plain English.

In this step, get ready to:
  • Set up a Flask application with a Gemini API client.
  • Write two system prompts that produce structured JSON output from Gemini.
  • Create API endpoints for code review and error explanation with error handling.
Set up the Flask app and Gemini client

Every request to DevLens flows through a single Python file called app.py. This file creates the Flask server, loads your API key securely, and connects to the Gemini API.

  • In VS Code, press Cmd+N (macOS) or Ctrl+N (Windows) to create a new file.
  • Save it as app.py by pressing Cmd+S (macOS) or Ctrl+S (Windows). Make sure it saves in your project folder alongside your .env file.
  • Add the following code to set up Flask and the Gemini client:
import os
import json
from flask import Flask, request, jsonify, send_from_directory
from dotenv import load_dotenv
from google import genai

# Load environment variables from the .env file
load_dotenv()

# Create the Flask app, pointing to a static folder for frontend files
app = Flask(__name__, static_folder="static")

# Load the API key and stop immediately if it's missing
api_key = os.environ.get("GEMINI_API_KEY")
if not api_key:
    raise ValueError("GEMINI_API_KEY environment variable is not set. Check your .env file.")

# Create the Gemini API client
client = genai.Client(api_key=api_key)

What does this code do?

load_dotenv() reads your .env file and makes GEMINI_API_KEY available as an environment variable. This keeps your key out of your source code.

Flask(__name__, static_folder="static") creates the Flask app and tells it where to find frontend files. You'll create that static folder in the next step.

genai.Client(api_key=api_key) creates a Gemini API client you'll use to send prompts and receive AI-generated analysis.

  • Save app.py.

You won't see a change yet. The next chunk adds the system prompts that shape Gemini's output.

Design the system prompts

The quality of an AI tool depends on how well you instruct the model. Each prompt below tells Gemini exactly what fields to return and what format to use.

This is prompt engineering in action. By requesting strict JSON output with named fields, you get predictable responses that your frontend can parse and display.

  • In app.py, add the following two system prompts below the client line:
REVIEW_SYSTEM_PROMPT = """You are a senior code reviewer. Analyze the provided code and return your findings as a JSON array. Each finding must have these fields:
- "severity": one of "critical", "warning", or "info"
- "title": a short title for the issue (max 10 words)
- "line": the approximate line number (integer, or 0 if general)
- "description": a clear explanation of the problem
- "fix": a suggested code fix or improvement

Focus on: bugs, security vulnerabilities, performance issues, and code style.
Return ONLY the JSON array, no markdown, no extra text. If the code is perfect, return an empty array: []"""

EXPLAIN_SYSTEM_PROMPT = """You are a debugging expert. The user will provide an error message and their code. Analyze the error and return a JSON object with these fields:
- "error_type": the type/category of error (e.g. "TypeError", "SyntaxError", "ImportError")
- "plain_explanation": a beginner-friendly explanation of what went wrong (2-3 sentences)
- "problematic_line": the line number causing the issue (integer, or 0 if unclear)
- "problematic_code": the exact code snippet causing the error
- "fixed_code": the corrected version of the problematic code
- "prevention_tip": one sentence on how to avoid this error in the future

Return ONLY the JSON object, no markdown, no extra text."""

Why ask for JSON instead of plain text?

AI models return freeform text by default. That's hard to display in a structured UI. By instructing Gemini to return a JSON array (for reviews) or JSON object (for explanations), you get data with consistent field names your JavaScript can parse and render.

The "Return ONLY the JSON" instruction is critical. Without it, Gemini often wraps its response in markdown code fences or adds commentary, which breaks json.loads() parsing.

  • Save app.py.

You won't see a change yet. The next chunk creates the API endpoints that send these prompts to Gemini.

Create the API endpoints with error handling

With the prompts defined, you can now build the routes that accept user input, call the Gemini API, and return structured results. Each endpoint validates the input, handles errors, and strips any unexpected formatting from Gemini's response.

  • In app.py, add the following code below the EXPLAIN_SYSTEM_PROMPT to create the static file routes, the code review endpoint, and the server entry point:
# Serve the frontend HTML when users visit the root URL
@app.route("/")
def serve_index():
    return send_from_directory(app.static_folder, "index.html")

# Serve any other static files (CSS, JS)
@app.route("/<path:path>")
def serve_static(path):
    return send_from_directory(app.static_folder, path)

# Code review endpoint - accepts POST requests with code to analyze
@app.route("/api/review", methods=["POST"])
def review_code():
    data = request.get_json()

    # Validate that code was provided
    if not data or not data.get("code", "").strip():
        return jsonify({"error": "No code provided."}), 400

    code = data["code"]
    language = data.get("language", "auto-detect")

    # Build the prompt with the user's code
    user_prompt = f"Language: {language}\n\nCode to review:\n```\n{code}\n```"

    try:
        # Send the prompt to Gemini with the review system instruction
        response = client.models.generate_content(
            model="gemini-2.5-flash",
            contents=user_prompt,
            config={
                "system_instruction": REVIEW_SYSTEM_PROMPT,
                "temperature": 0.2,
            },
        )

        # Strip markdown code fences if Gemini adds them
        result_text = response.text.strip()
        if result_text.startswith("```"):
            result_text = result_text.split("\n", 1)[1]
            result_text = result_text.rsplit("```", 1)[0]

        # Parse the JSON response and return findings
        findings = json.loads(result_text)
        return jsonify({"findings": findings})

    except json.JSONDecodeError:
        # If JSON parsing fails, return the raw text so nothing is lost
        return jsonify({"findings": [], "raw": response.text}), 200
    except Exception as e:
        # Catch any Gemini API errors
        return jsonify({"error": f"AI service error: {str(e)}"}), 500


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

What does this code do?

serve_index and serve_static serve the frontend files from the static folder. This lets you run the entire app with a single python app.py command instead of needing a separate web server.

The review_code function validates the request, builds a prompt with the user's code, and sends it to Gemini. The markdown-stripping logic handles cases where Gemini wraps its JSON response in code fences despite being told not to.

Setting temperature to 0.2 makes Gemini's output more deterministic. Lower temperature means less creative variation, which is exactly what you want for structured JSON responses.

  • Save app.py.
  • Start the server by running this command in your terminal:
python app.py

You should see Flask's development server start up with a message showing Debug mode: on and a URL containing port 5000.

Why does it mention the static folder?

You might see a warning or a 404 if you visit http://127.0.0.1:5000 in your browser right now. That's expected. The static folder doesn't exist yet. You'll create it in the next step when you build the frontend.

🙋‍♀️ Server not starting?

  • If you see ModuleNotFoundError, your virtual environment might not be active. Look for (venv) or a similar prefix in your terminal prompt.
  • If you see ValueError: GEMINI_API_KEY environment variable is not set, check that your .env file is in the same folder as app.py and contains your actual API key.
  • If port 5000 is already in use, stop the other process or change the port number in the app.run() call.

Still stuck? Help me debug why my Flask server won't start.

Now add the second endpoint. Keep the server running. Flask's debug mode automatically reloads when you save changes to app.py.

  • In app.py, find the line that says if __name__ == "__main__": and add the following code just above it:
# Error explanation endpoint - accepts POST requests with an error and code
@app.route("/api/explain", methods=["POST"])
def explain_error():
    data = request.get_json()

    # Validate that an error message was provided
    if not data or not data.get("error_message", "").strip():
        return jsonify({"error": "No error message provided."}), 400

    error_message = data["error_message"]
    code = data.get("code", "No code provided")

    # Build the prompt with the error message and code
    user_prompt = f"Error message:\n{error_message}\n\nCode:\n```\n{code}\n```"

    try:
        # Send the prompt to Gemini with the explain system instruction
        response = client.models.generate_content(
            model="gemini-2.5-flash",
            contents=user_prompt,
            config={
                "system_instruction": EXPLAIN_SYSTEM_PROMPT,
                "temperature": 0.2,
            },
        )

        # Strip markdown code fences if Gemini adds them
        result_text = response.text.strip()
        if result_text.startswith("```"):
            result_text = result_text.split("\n", 1)[1]
            result_text = result_text.rsplit("```", 1)[0]

        # Parse the JSON response and return the explanation
        explanation = json.loads(result_text)
        return jsonify({"explanation": explanation})

    except json.JSONDecodeError:
        # If JSON parsing fails, return the raw text
        return jsonify({"explanation": None, "raw": response.text}), 200
    except Exception as e:
        # Catch any Gemini API errors
        return jsonify({"error": f"AI service error: {str(e)}"}), 500

What does this endpoint do?

explain_error follows the same pattern as review_code but accepts an error_message field instead of a language selector. It sends the error and the user's code to Gemini, which returns a structured explanation with the problematic line, a fix, and a prevention tip.

Both endpoints share the same error handling strategy. A json.JSONDecodeError means Gemini returned text that isn't valid JSON. A general Exception catches network failures or API errors. Either way, the frontend gets a meaningful response instead of a crash.

  • Save app.py.
  • Check your terminal. Flask automatically detects the change and reloads the server.

Server crashed after saving?

  • Check your indentation. The explain_error function must be at the same indentation level as review_code (no extra indent).
  • Make sure the new code is above the if __name__ == "__main__": line, not below it.
  • If the server stopped entirely, restart it by running python app.py again.

Still stuck? Help me debug a Flask syntax or indentation error.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.py and your server is still running.

ⓧ I'd like to double check the full code

Here is the complete app.py file as it should look at the end of this step:

import os
import json
from flask import Flask, request, jsonify, send_from_directory
from dotenv import load_dotenv
from google import genai

load_dotenv()

app = Flask(__name__, static_folder="static")

api_key = os.environ.get("GEMINI_API_KEY")
if not api_key:
    raise ValueError("GEMINI_API_KEY environment variable is not set. Check your .env file.")

client = genai.Client(api_key=api_key)

REVIEW_SYSTEM_PROMPT = """You are a senior code reviewer. Analyze the provided code and return your findings as a JSON array. Each finding must have these fields:
- "severity": one of "critical", "warning", or "info"
- "title": a short title for the issue (max 10 words)
- "line": the approximate line number (integer, or 0 if general)
- "description": a clear explanation of the problem
- "fix": a suggested code fix or improvement

Focus on: bugs, security vulnerabilities, performance issues, and code style.
Return ONLY the JSON array, no markdown, no extra text. If the code is perfect, return an empty array: []"""

EXPLAIN_SYSTEM_PROMPT = """You are a debugging expert. The user will provide an error message and their code. Analyze the error and return a JSON object with these fields:
- "error_type": the type/category of error (e.g. "TypeError", "SyntaxError", "ImportError")
- "plain_explanation": a beginner-friendly explanation of what went wrong (2-3 sentences)
- "problematic_line": the line number causing the issue (integer, or 0 if unclear)
- "problematic_code": the exact code snippet causing the error
- "fixed_code": the corrected version of the problematic code
- "prevention_tip": one sentence on how to avoid this error in the future

Return ONLY the JSON object, no markdown, no extra text."""


@app.route("/")
def serve_index():
    return send_from_directory(app.static_folder, "index.html")


@app.route("/<path:path>")
def serve_static(path):
    return send_from_directory(app.static_folder, path)


@app.route("/api/review", methods=["POST"])
def review_code():
    data = request.get_json()

    if not data or not data.get("code", "").strip():
        return jsonify({"error": "No code provided."}), 400

    code = data["code"]
    language = data.get("language", "auto-detect")

    user_prompt = f"Language: {language}\n\nCode to review:\n```\n{code}\n```"

    try:
        response = client.models.generate_content(
            model="gemini-2.5-flash",
            contents=user_prompt,
            config={
                "system_instruction": REVIEW_SYSTEM_PROMPT,
                "temperature": 0.2,
            },
        )

        result_text = response.text.strip()
        if result_text.startswith("```"):
            result_text = result_text.split("\n", 1)[1]
            result_text = result_text.rsplit("```", 1)[0]

        findings = json.loads(result_text)
        return jsonify({"findings": findings})

    except json.JSONDecodeError:
        return jsonify({"findings": [], "raw": response.text}), 200
    except Exception as e:
        return jsonify({"error": f"AI service error: {str(e)}"}), 500


@app.route("/api/explain", methods=["POST"])
def explain_error():
    data = request.get_json()

    if not data or not data.get("error_message", "").strip():
        return jsonify({"error": "No error message provided."}), 400

    error_message = data["error_message"]
    code = data.get("code", "No code provided")

    user_prompt = f"Error message:\n{error_message}\n\nCode:\n```\n{code}\n```"

    try:
        response = client.models.generate_content(
            model="gemini-2.5-flash",
            contents=user_prompt,
            config={
                "system_instruction": EXPLAIN_SYSTEM_PROMPT,
                "temperature": 0.2,
            },
        )

        result_text = response.text.strip()
        if result_text.startswith("```"):
            result_text = result_text.split("\n", 1)[1]
            result_text = result_text.rsplit("```", 1)[0]

        explanation = json.loads(result_text)
        return jsonify({"explanation": explanation})

    except json.JSONDecodeError:
        return jsonify({"explanation": None, "raw": response.text}), 200
    except Exception as e:
        return jsonify({"error": f"AI service error: {str(e)}"}), 500


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

Your backend API is live and both endpoints are ready to receive requests. Next up, you'll build the frontend that sends code to these endpoints and displays the AI's analysis with a polished dark-themed UI.

Build the Frontend UI

Your Flask API is ready to accept code and return AI-powered analysis. Now it needs a frontend that lets users interact with both endpoints.

In this step, you'll build a complete single-page interface using HTML, CSS, and JavaScript. The page has a tabbed layout, a professional dark theme, and JavaScript functions that send requests to your API and render the results.

In this step, get ready to:
  • Create the HTML page with a tabbed interface for Code Review and Error Explainer.
  • Style the page with a professional dark theme and severity badges.
  • Write JavaScript to send requests to your API and render the results.
Create the static folder and files

Flask serves frontend files from a folder called static. Your app.py is already configured to look for it because of the static_folder="static" parameter you set in Step 2.

  • Create the static folder and the three frontend files by running these commands:
mkdir static
touch static/index.html static/style.css static/script.js
  • Confirm the files were created by running:
ls static

You should see index.html, script.js, and style.css listed in the output.

Not seeing all three files?

Make sure you ran both commands from your project's root folder (the one that contains app.py). If static already existed, the mkdir command may show an error. That is fine. Re-run the touch command to create any missing files.

Help me create the static folder and files for my Flask project.

Build the HTML structure

The HTML page is a single-page interface with two tabs. The Code Review tab has a language selector dropdown and a code textarea. The Error Explainer tab has textareas for both the error message and the code that caused it.

  • Open static/index.html in VS Code and add 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>DevLens - AI Code Review &amp; Error Debugger</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <!-- App header -->
    <header>
        <h1>DevLens</h1>
        <p>AI-Powered Code Review &amp; Error Debugger</p>
    </header>

    <main>
        <!-- Tab buttons for switching between modes -->
        <div class="tabs">
            <button class="tab active" data-tab="review">Code Review</button>
            <button class="tab" data-tab="explain">Error Explainer</button>
        </div>

        <!-- Code Review panel with language selector and code input -->
        <div id="review-panel" class="panel active">
            <div class="input-group">
                <label for="review-language">Language</label>
                <select id="review-language">
                    <option value="auto-detect">Auto-detect</option>
                    <option value="python">Python</option>
                    <option value="javascript">JavaScript</option>
                    <option value="html">HTML</option>
                    <option value="css">CSS</option>
                    <option value="java">Java</option>
                    <option value="c">C</option>
                    <option value="cpp">C++</option>
                </select>
            </div>
            <div class="input-group">
                <label for="review-code">Paste your code</label>
                <textarea id="review-code" rows="12" placeholder="Paste your code here..."></textarea>
            </div>
            <button id="review-btn" class="submit-btn">Review Code</button>
        </div>

        <!-- Error Explainer panel with error message and code inputs -->
        <div id="explain-panel" class="panel">
            <div class="input-group">
                <label for="error-message">Error message</label>
                <textarea id="error-message" rows="4" placeholder="Paste your error message here..."></textarea>
            </div>
            <div class="input-group">
                <label for="explain-code">Your code</label>
                <textarea id="explain-code" rows="10" placeholder="Paste the code that produced the error..."></textarea>
            </div>
            <button id="explain-btn" class="submit-btn">Explain Error</button>
        </div>

        <!-- Loading spinner shown while AI is processing -->
        <div id="loading" class="loading hidden">
            <div class="spinner"></div>
            <p>Analyzing with AI...</p>
        </div>

        <!-- Results display area populated by JavaScript -->
        <div id="results" class="results"></div>
    </main>

    <script src="script.js"></script>
</body>
</html>

What does this HTML do?

  • The two <button> elements with data-tab attributes create the tab bar. JavaScript reads these attributes to know which panel to show.
  • review-panel starts with the active class, making it the default visible panel. explain-panel is hidden until the user clicks its tab.
  • The loading div starts hidden. JavaScript removes the hidden class while the AI is processing, showing a spinner animation.
  • The empty results div is where JavaScript injects the AI analysis as formatted HTML.
  • Save static/index.html.
  • Open the file in your browser by double-clicking it in Finder (macOS) or File Explorer (Windows). You should see unstyled text with a heading, two buttons, dropdown, and textareas.

Seeing a blank page?

Check that you saved the file. Also make sure you opened index.html from inside the static folder, not the project root.

Help me figure out why my HTML page is blank in the browser.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>DevLens - AI Code Review &amp; Error Debugger</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>DevLens</h1>
        <p>AI-Powered Code Review &amp; Error Debugger</p>
    </header>

    <main>
        <div class="tabs">
            <button class="tab active" data-tab="review">Code Review</button>
            <button class="tab" data-tab="explain">Error Explainer</button>
        </div>

        <div id="review-panel" class="panel active">
            <div class="input-group">
                <label for="review-language">Language</label>
                <select id="review-language">
                    <option value="auto-detect">Auto-detect</option>
                    <option value="python">Python</option>
                    <option value="javascript">JavaScript</option>
                    <option value="html">HTML</option>
                    <option value="css">CSS</option>
                    <option value="java">Java</option>
                    <option value="c">C</option>
                    <option value="cpp">C++</option>
                </select>
            </div>
            <div class="input-group">
                <label for="review-code">Paste your code</label>
                <textarea id="review-code" rows="12" placeholder="Paste your code here..."></textarea>
            </div>
            <button id="review-btn" class="submit-btn">Review Code</button>
        </div>

        <div id="explain-panel" class="panel">
            <div class="input-group">
                <label for="error-message">Error message</label>
                <textarea id="error-message" rows="4" placeholder="Paste your error message here..."></textarea>
            </div>
            <div class="input-group">
                <label for="explain-code">Your code</label>
                <textarea id="explain-code" rows="10" placeholder="Paste the code that produced the error..."></textarea>
            </div>
            <button id="explain-btn" class="submit-btn">Explain Error</button>
        </div>

        <div id="loading" class="loading hidden">
            <div class="spinner"></div>
            <p>Analyzing with AI...</p>
        </div>

        <div id="results" class="results"></div>
    </main>

    <script src="script.js"></script>
</body>
</html>
Style the page with a dark theme

The page works but looks bare. You'll add a dark developer-themed stylesheet with styled tabs, form controls, and color-coded severity badges for the AI results.

  • Open static/style.css and add the base layout and form styles:
/* Reset default browser styles */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

/* Dark background for the full page */
body {
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
    background-color: #0d1117;
    color: #c9d1d9;
    min-height: 100vh;
}

/* Centered header with accent color */
header {
    text-align: center;
    padding: 2rem 1rem 1rem;
}

header h1 {
    font-size: 2rem;
    color: #58a6ff;
}

header p {
    color: #8b949e;
    margin-top: 0.25rem;
}

/* Constrain content width */
main {
    max-width: 800px;
    margin: 1.5rem auto;
    padding: 0 1rem;
}

/* Tab bar with bottom border */
.tabs {
    display: flex;
    gap: 0;
    border-bottom: 1px solid #30363d;
    margin-bottom: 1.5rem;
}

/* Individual tab button styling */
.tab {
    padding: 0.75rem 1.5rem;
    background: none;
    border: none;
    color: #8b949e;
    cursor: pointer;
    font-size: 0.95rem;
    border-bottom: 2px solid transparent;
    transition: color 0.2s, border-color 0.2s;
}

.tab:hover {
    color: #c9d1d9;
}

/* Active tab gets blue underline */
.tab.active {
    color: #58a6ff;
    border-bottom-color: #58a6ff;
}

/* Panels toggle visibility via the active class */
.panel {
    display: none;
}

.panel.active {
    display: block;
}

/* Form field spacing */
.input-group {
    margin-bottom: 1rem;
}

.input-group label {
    display: block;
    margin-bottom: 0.4rem;
    color: #8b949e;
    font-size: 0.9rem;
}

/* Dark-themed textareas and selects */
textarea, select {
    width: 100%;
    padding: 0.75rem;
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    color: #c9d1d9;
    font-family: "Courier New", Courier, monospace;
    font-size: 0.9rem;
    resize: vertical;
}

textarea:focus, select:focus {
    outline: none;
    border-color: #58a6ff;
}

select {
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
    cursor: pointer;
}

What does this CSS do?

  • The #0d1117 background matches the dark color scheme used by GitHub. The #58a6ff accent color highlights the active tab and focused inputs.
  • The .panel class uses display: none by default. Only the panel with the active class is visible. JavaScript toggles this class when the user clicks a tab.
  • Save style.css and refresh your browser.
  • The page should now have a dark background, a blue "DevLens" heading, and styled form fields with dark inputs.
  • Next, add the button and loading spinner styles below what you just wrote:
/* Green submit button with disabled state */
.submit-btn {
    padding: 0.75rem 2rem;
    background-color: #238636;
    color: #ffffff;
    border: none;
    border-radius: 6px;
    font-size: 1rem;
    cursor: pointer;
    transition: background-color 0.2s;
}

.submit-btn:hover {
    background-color: #2ea043;
}

.submit-btn:disabled {
    background-color: #21262d;
    color: #484f58;
    cursor: not-allowed;
}

/* Loading overlay centered on page */
.loading {
    text-align: center;
    padding: 2rem;
}

.hidden {
    display: none;
}

/* Spinning circle animation */
.spinner {
    width: 40px;
    height: 40px;
    border: 3px solid #30363d;
    border-top-color: #58a6ff;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin: 0 auto 1rem;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

What does this CSS do?

  • The .submit-btn:disabled rule grays out the button while the AI is processing. This prevents the user from sending duplicate requests.
  • The .spinner creates a circle with one blue edge. The @keyframes spin rotates it endlessly, creating a loading animation.
  • Save style.css and refresh your browser.
  • You should see a green Review Code button below the textarea.
  • Finally, add the results display styles below the spinner animation:
/* Results container spacing */
.results {
    margin-top: 1.5rem;
}

/* Individual finding card */
.finding {
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    padding: 1rem;
    margin-bottom: 0.75rem;
}

.finding-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}

/* Severity badge base style */
.badge {
    padding: 0.15rem 0.5rem;
    border-radius: 12px;
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
}

/* Color-coded severity levels */
.badge.critical {
    background-color: #da3633;
    color: #ffffff;
}

.badge.warning {
    background-color: #d29922;
    color: #000000;
}

.badge.info {
    background-color: #388bfd;
    color: #ffffff;
}

.finding-title {
    font-weight: 600;
    color: #c9d1d9;
}

.finding-line {
    color: #8b949e;
    font-size: 0.85rem;
}

.finding-description {
    margin-bottom: 0.5rem;
    line-height: 1.5;
}

/* Code fix suggestion with green left border */
.finding-fix {
    background-color: #0d1117;
    padding: 0.75rem;
    border-radius: 4px;
    font-family: "Courier New", Courier, monospace;
    font-size: 0.85rem;
    white-space: pre-wrap;
    border-left: 3px solid #238636;
}

/* Error Explainer result card */
.explanation-card {
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    padding: 1.5rem;
}

.explanation-card h3 {
    color: #58a6ff;
    margin-bottom: 1rem;
}

.explanation-section {
    margin-bottom: 1rem;
}

.explanation-section h4 {
    color: #8b949e;
    font-size: 0.85rem;
    text-transform: uppercase;
    margin-bottom: 0.4rem;
}

.explanation-section p {
    line-height: 1.6;
}

/* Code snippets inside explanation cards */
.code-block {
    background-color: #0d1117;
    padding: 0.75rem;
    border-radius: 4px;
    font-family: "Courier New", Courier, monospace;
    font-size: 0.85rem;
    white-space: pre-wrap;
    margin-top: 0.25rem;
}

/* Red border for problematic code */
.code-block.error-code {
    border-left: 3px solid #da3633;
}

/* Green border for corrected code */
.code-block.fixed-code {
    border-left: 3px solid #238636;
}

.no-issues {
    text-align: center;
    padding: 2rem;
    color: #238636;
    font-size: 1.1rem;
}

.error-message {
    background-color: #161b22;
    border: 1px solid #da3633;
    border-radius: 6px;
    padding: 1rem;
    color: #f85149;
}

What do the severity badges look like?

Each finding from the AI comes with a severity level. The CSS maps these to colors: critical is red, warning is yellow, and info is blue.

The .finding-fix and .code-block classes use colored left borders to visually distinguish problematic code (red) from corrected code (green).

  • Save style.css.

You won't see these result styles yet because there are no results to display. They will appear when you test the full app in the next step.

Page still looks unstyled?

Make sure style.css is in the same static folder as index.html. The link tag in your HTML references href="style.css", which is a relative path. Both files must be in the same directory for the browser to find the stylesheet.

Help me figure out why my CSS is not loading.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
    background-color: #0d1117;
    color: #c9d1d9;
    min-height: 100vh;
}

header {
    text-align: center;
    padding: 2rem 1rem 1rem;
}

header h1 {
    font-size: 2rem;
    color: #58a6ff;
}

header p {
    color: #8b949e;
    margin-top: 0.25rem;
}

main {
    max-width: 800px;
    margin: 1.5rem auto;
    padding: 0 1rem;
}

.tabs {
    display: flex;
    gap: 0;
    border-bottom: 1px solid #30363d;
    margin-bottom: 1.5rem;
}

.tab {
    padding: 0.75rem 1.5rem;
    background: none;
    border: none;
    color: #8b949e;
    cursor: pointer;
    font-size: 0.95rem;
    border-bottom: 2px solid transparent;
    transition: color 0.2s, border-color 0.2s;
}

.tab:hover {
    color: #c9d1d9;
}

.tab.active {
    color: #58a6ff;
    border-bottom-color: #58a6ff;
}

.panel {
    display: none;
}

.panel.active {
    display: block;
}

.input-group {
    margin-bottom: 1rem;
}

.input-group label {
    display: block;
    margin-bottom: 0.4rem;
    color: #8b949e;
    font-size: 0.9rem;
}

textarea, select {
    width: 100%;
    padding: 0.75rem;
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    color: #c9d1d9;
    font-family: "Courier New", Courier, monospace;
    font-size: 0.9rem;
    resize: vertical;
}

textarea:focus, select:focus {
    outline: none;
    border-color: #58a6ff;
}

select {
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
    cursor: pointer;
}

.submit-btn {
    padding: 0.75rem 2rem;
    background-color: #238636;
    color: #ffffff;
    border: none;
    border-radius: 6px;
    font-size: 1rem;
    cursor: pointer;
    transition: background-color 0.2s;
}

.submit-btn:hover {
    background-color: #2ea043;
}

.submit-btn:disabled {
    background-color: #21262d;
    color: #484f58;
    cursor: not-allowed;
}

.loading {
    text-align: center;
    padding: 2rem;
}

.hidden {
    display: none;
}

.spinner {
    width: 40px;
    height: 40px;
    border: 3px solid #30363d;
    border-top-color: #58a6ff;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin: 0 auto 1rem;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

.results {
    margin-top: 1.5rem;
}

.finding {
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    padding: 1rem;
    margin-bottom: 0.75rem;
}

.finding-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}

.badge {
    padding: 0.15rem 0.5rem;
    border-radius: 12px;
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
}

.badge.critical {
    background-color: #da3633;
    color: #ffffff;
}

.badge.warning {
    background-color: #d29922;
    color: #000000;
}

.badge.info {
    background-color: #388bfd;
    color: #ffffff;
}

.finding-title {
    font-weight: 600;
    color: #c9d1d9;
}

.finding-line {
    color: #8b949e;
    font-size: 0.85rem;
}

.finding-description {
    margin-bottom: 0.5rem;
    line-height: 1.5;
}

.finding-fix {
    background-color: #0d1117;
    padding: 0.75rem;
    border-radius: 4px;
    font-family: "Courier New", Courier, monospace;
    font-size: 0.85rem;
    white-space: pre-wrap;
    border-left: 3px solid #238636;
}

.explanation-card {
    background-color: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    padding: 1.5rem;
}

.explanation-card h3 {
    color: #58a6ff;
    margin-bottom: 1rem;
}

.explanation-section {
    margin-bottom: 1rem;
}

.explanation-section h4 {
    color: #8b949e;
    font-size: 0.85rem;
    text-transform: uppercase;
    margin-bottom: 0.4rem;
}

.explanation-section p {
    line-height: 1.6;
}

.code-block {
    background-color: #0d1117;
    padding: 0.75rem;
    border-radius: 4px;
    font-family: "Courier New", Courier, monospace;
    font-size: 0.85rem;
    white-space: pre-wrap;
    margin-top: 0.25rem;
}

.code-block.error-code {
    border-left: 3px solid #da3633;
}

.code-block.fixed-code {
    border-left: 3px solid #238636;
}

.no-issues {
    text-align: center;
    padding: 2rem;
    color: #238636;
    font-size: 1.1rem;
}

.error-message {
    background-color: #161b22;
    border: 1px solid #da3633;
    border-radius: 6px;
    padding: 1rem;
    color: #f85149;
}
Wire up the JavaScript

The page looks great, but the buttons don't do anything yet. You need JavaScript to handle tab switching, send user input to your Flask API using the fetch API, and render the JSON responses as formatted HTML.

  • Open static/script.js and add the following code to set up DOM references, tab switching, and button click handlers:
document.addEventListener("DOMContentLoaded", function () {
    // Cache references to DOM elements used throughout the script
    const tabs = document.querySelectorAll(".tab");
    const reviewPanel = document.getElementById("review-panel");
    const explainPanel = document.getElementById("explain-panel");
    const reviewBtn = document.getElementById("review-btn");
    const explainBtn = document.getElementById("explain-btn");
    const loading = document.getElementById("loading");
    const results = document.getElementById("results");

    // Tab switching: toggle active class on tabs and panels
    tabs.forEach(function (tab) {
        tab.addEventListener("click", function () {
            tabs.forEach(function (t) { t.classList.remove("active"); });
            tab.classList.add("active");

            var target = tab.getAttribute("data-tab");
            reviewPanel.classList.toggle("active", target === "review");
            explainPanel.classList.toggle("active", target === "explain");
            results.innerHTML = "";
        });
    });

    // Code Review button: read input and send to /api/review
    reviewBtn.addEventListener("click", function () {
        var code = document.getElementById("review-code").value.trim();
        var language = document.getElementById("review-language").value;

        if (!code) {
            results.innerHTML = '<div class="error-message">Please paste some code to review.</div>';
            return;
        }

        sendRequest("/api/review", { code: code, language: language }, renderReview);
    });

    // Error Explainer button: read input and send to /api/explain
    explainBtn.addEventListener("click", function () {
        var errorMessage = document.getElementById("error-message").value.trim();
        var code = document.getElementById("explain-code").value.trim();

        if (!errorMessage) {
            results.innerHTML = '<div class="error-message">Please paste an error message.</div>';
            return;
        }

        sendRequest("/api/explain", { error_message: errorMessage, code: code }, renderExplanation);
    });

What does this code do?

  • Everything is wrapped in DOMContentLoaded so the script waits until the HTML is fully loaded before running.
  • The tab switching logic removes active from all tabs, adds it to the clicked one, then uses classList.toggle to show the matching panel and hide the other.
  • Both click handlers validate the input first. If the user clicks without entering anything, an error message appears in the results area instead of sending an empty request to the API.

Save the file. You won't see a change yet because the rendering functions are still missing.

  • Still in script.js, add the following functions directly below the click handlers:
    // Send a POST request to the API, then call renderFn with the result
    function sendRequest(url, body, renderFn) {
        loading.classList.remove("hidden");
        results.innerHTML = "";
        reviewBtn.disabled = true;
        explainBtn.disabled = true;

        fetch(url, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(body),
        })
        .then(function (res) { return res.json(); })
        .then(function (data) {
            loading.classList.add("hidden");
            reviewBtn.disabled = false;
            explainBtn.disabled = false;

            if (data.error) {
                results.innerHTML = '<div class="error-message">' + escapeHtml(data.error) + '</div>';
                return;
            }

            renderFn(data);
        })
        .catch(function (err) {
            loading.classList.add("hidden");
            reviewBtn.disabled = false;
            explainBtn.disabled = false;
            results.innerHTML = '<div class="error-message">Network error. Is the server running?</div>';
        });
    }

    // Build HTML for each finding card with severity badge
    function renderReview(data) {
        var findings = data.findings;

        if (!findings || findings.length === 0) {
            results.innerHTML = '<div class="no-issues">No issues found. Your code looks good!</div>';
            return;
        }

        var html = "";
        findings.forEach(function (f) {
            html += '<div class="finding">';
            html += '<div class="finding-header">';
            html += '<span class="badge ' + escapeHtml(f.severity) + '">' + escapeHtml(f.severity) + '</span>';
            html += '<span class="finding-title">' + escapeHtml(f.title) + '</span>';
            if (f.line && f.line > 0) {
                html += '<span class="finding-line">Line ' + f.line + '</span>';
            }
            html += '</div>';
            html += '<p class="finding-description">' + escapeHtml(f.description) + '</p>';
            if (f.fix) {
                html += '<div class="finding-fix">' + escapeHtml(f.fix) + '</div>';
            }
            html += '</div>';
        });

        results.innerHTML = html;
    }

    // Build HTML for the error explanation card
    function renderExplanation(data) {
        var e = data.explanation;

        if (!e) {
            results.innerHTML = '<div class="error-message">Could not parse the AI response. Try again.</div>';
            return;
        }

        var html = '<div class="explanation-card">';
        html += '<h3>' + escapeHtml(e.error_type || "Error") + '</h3>';

        html += '<div class="explanation-section">';
        html += '<h4>What happened</h4>';
        html += '<p>' + escapeHtml(e.plain_explanation || "") + '</p>';
        html += '</div>';

        if (e.problematic_code) {
            html += '<div class="explanation-section">';
            html += '<h4>Problematic code</h4>';
            html += '<div class="code-block error-code">' + escapeHtml(e.problematic_code) + '</div>';
            html += '</div>';
        }

        if (e.fixed_code) {
            html += '<div class="explanation-section">';
            html += '<h4>Fixed code</h4>';
            html += '<div class="code-block fixed-code">' + escapeHtml(e.fixed_code) + '</div>';
            html += '</div>';
        }

        if (e.prevention_tip) {
            html += '<div class="explanation-section">';
            html += '<h4>Prevention tip</h4>';
            html += '<p>' + escapeHtml(e.prevention_tip) + '</p>';
            html += '</div>';
        }

        html += '</div>';
        results.innerHTML = html;
    }

    // Safely escape user-provided text to prevent XSS
    function escapeHtml(text) {
        var div = document.createElement("div");
        div.textContent = String(text);
        return div.innerHTML;
    }
});

What do these functions do?

  • sendRequest shows the loading spinner, disables both buttons, and uses fetch to send a POST request to the given URL. When the response arrives, it hides the spinner, re-enables the buttons, and calls the appropriate render function.
  • renderReview loops over the findings array from the API response and builds a card for each finding. Each card shows a color-coded severity badge, the issue title, a description, and a code fix.
  • renderExplanation builds a single card showing the error type, a plain-English explanation, the problematic code snippet, the corrected code, and a prevention tip.
  • escapeHtml prevents XSS attacks by converting any HTML characters in the AI response into safe text. This is important because the AI response could contain <script> tags or other HTML that should not be executed.
  • Save script.js and refresh your browser.
  • Click the Error Explainer tab. The panel should switch to show the error message and code textareas. Click Code Review to switch back.

Tabs not switching?

Open your browser's developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows). Look for red error messages. A common issue is a missing quotation mark or bracket that breaks the entire script.

Also check that your script.js file is in the static folder alongside index.html. The <script src="script.js"> tag uses a relative path.

Help me debug why my tab switching is not working.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

document.addEventListener("DOMContentLoaded", function () {
    const tabs = document.querySelectorAll(".tab");
    const reviewPanel = document.getElementById("review-panel");
    const explainPanel = document.getElementById("explain-panel");
    const reviewBtn = document.getElementById("review-btn");
    const explainBtn = document.getElementById("explain-btn");
    const loading = document.getElementById("loading");
    const results = document.getElementById("results");

    tabs.forEach(function (tab) {
        tab.addEventListener("click", function () {
            tabs.forEach(function (t) { t.classList.remove("active"); });
            tab.classList.add("active");

            var target = tab.getAttribute("data-tab");
            reviewPanel.classList.toggle("active", target === "review");
            explainPanel.classList.toggle("active", target === "explain");
            results.innerHTML = "";
        });
    });

    reviewBtn.addEventListener("click", function () {
        var code = document.getElementById("review-code").value.trim();
        var language = document.getElementById("review-language").value;

        if (!code) {
            results.innerHTML = '<div class="error-message">Please paste some code to review.</div>';
            return;
        }

        sendRequest("/api/review", { code: code, language: language }, renderReview);
    });

    explainBtn.addEventListener("click", function () {
        var errorMessage = document.getElementById("error-message").value.trim();
        var code = document.getElementById("explain-code").value.trim();

        if (!errorMessage) {
            results.innerHTML = '<div class="error-message">Please paste an error message.</div>';
            return;
        }

        sendRequest("/api/explain", { error_message: errorMessage, code: code }, renderExplanation);
    });

    function sendRequest(url, body, renderFn) {
        loading.classList.remove("hidden");
        results.innerHTML = "";
        reviewBtn.disabled = true;
        explainBtn.disabled = true;

        fetch(url, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(body),
        })
        .then(function (res) { return res.json(); })
        .then(function (data) {
            loading.classList.add("hidden");
            reviewBtn.disabled = false;
            explainBtn.disabled = false;

            if (data.error) {
                results.innerHTML = '<div class="error-message">' + escapeHtml(data.error) + '</div>';
                return;
            }

            renderFn(data);
        })
        .catch(function (err) {
            loading.classList.add("hidden");
            reviewBtn.disabled = false;
            explainBtn.disabled = false;
            results.innerHTML = '<div class="error-message">Network error. Is the server running?</div>';
        });
    }

    function renderReview(data) {
        var findings = data.findings;

        if (!findings || findings.length === 0) {
            results.innerHTML = '<div class="no-issues">No issues found. Your code looks good!</div>';
            return;
        }

        var html = "";
        findings.forEach(function (f) {
            html += '<div class="finding">';
            html += '<div class="finding-header">';
            html += '<span class="badge ' + escapeHtml(f.severity) + '">' + escapeHtml(f.severity) + '</span>';
            html += '<span class="finding-title">' + escapeHtml(f.title) + '</span>';
            if (f.line && f.line > 0) {
                html += '<span class="finding-line">Line ' + f.line + '</span>';
            }
            html += '</div>';
            html += '<p class="finding-description">' + escapeHtml(f.description) + '</p>';
            if (f.fix) {
                html += '<div class="finding-fix">' + escapeHtml(f.fix) + '</div>';
            }
            html += '</div>';
        });

        results.innerHTML = html;
    }

    function renderExplanation(data) {
        var e = data.explanation;

        if (!e) {
            results.innerHTML = '<div class="error-message">Could not parse the AI response. Try again.</div>';
            return;
        }

        var html = '<div class="explanation-card">';
        html += '<h3>' + escapeHtml(e.error_type || "Error") + '</h3>';

        html += '<div class="explanation-section">';
        html += '<h4>What happened</h4>';
        html += '<p>' + escapeHtml(e.plain_explanation || "") + '</p>';
        html += '</div>';

        if (e.problematic_code) {
            html += '<div class="explanation-section">';
            html += '<h4>Problematic code</h4>';
            html += '<div class="code-block error-code">' + escapeHtml(e.problematic_code) + '</div>';
            html += '</div>';
        }

        if (e.fixed_code) {
            html += '<div class="explanation-section">';
            html += '<h4>Fixed code</h4>';
            html += '<div class="code-block fixed-code">' + escapeHtml(e.fixed_code) + '</div>';
            html += '</div>';
        }

        if (e.prevention_tip) {
            html += '<div class="explanation-section">';
            html += '<h4>Prevention tip</h4>';
            html += '<p>' + escapeHtml(e.prevention_tip) + '</p>';
            html += '</div>';
        }

        html += '</div>';
        results.innerHTML = html;
    }

    function escapeHtml(text) {
        var div = document.createElement("div");
        div.textContent = String(text);
        return div.innerHTML;
    }
});

Your frontend is complete with a dark-themed tabbed interface. Next up, you'll serve it from Flask and test both modes end-to-end with real code and real errors.

Connect Frontend to Backend and Polish

In the last step, you built DevLens's frontend with HTML, CSS, and JavaScript. You have a tabbed interface, styled inputs, and rendering functions ready to display AI analysis.

Before testing, it helps to understand how Flask connects the backend API to the frontend files from a single server. Then you will start the server and put both modes through a real test.

In this step, get ready to:
  • Understand how Flask serves the frontend from the same server as the API.
  • Review the loading state and button-disabling logic.
  • Start the server and test both modes end-to-end.
Understand how Flask serves the frontend

Your app.py already handles both API requests and frontend file delivery. Two route functions make this possible.

  • In VS Code, open app.py and scroll to the serve_index() function on the @app.route("/") route.
  • Find the serve_static() function on the @app.route("/<path:path>") route right below it.

How does single-server architecture work?

When someone visits http://localhost:5000, Flask calls serve_index() which returns index.html from the static folder. The browser then requests style.css and script.js, and the catch-all serve_static() route handles those.

Your API endpoints (/api/review and /api/explain) also live on the same server. This means the JavaScript fetch calls work without any cross-origin issues.

Review the loading state

Your frontend includes a loading state that shows a spinner and disables the submit buttons while the AI processes a request. Three files work together to make this happen.

How do the loading pieces connect?

  • In index.html, the <div id="loading"> contains a spinner element and an "Analyzing with AI..." message. It starts hidden with the hidden class.
  • In style.css, the .spinner class creates a rotating blue circle with a CSS animation. The .hidden class sets display: none to keep it out of view.
  • In script.js, the sendRequest() function removes the hidden class to reveal the spinner when a request starts. It also sets both buttons to disabled = true to prevent double-clicks. When the response arrives, it hides the spinner and re-enables the buttons.
Start the server and test both modes
  • Switch back to your terminal and start the Flask development server by running this command:
python app.py
  • Your terminal shows that the server is running at http://127.0.0.1:5000.

Server not starting?

  • If you see a ModuleNotFoundError, your virtual environment might not be active. Run source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows) and try again.
  • If you see a ValueError about GEMINI_API_KEY, make sure your .env file is in the project root (not inside the static folder) and contains your API key.
  • If the port is already in use, another process might be running on port 5000. Close any other Flask servers or apps using that port and try again.

Help me troubleshoot my Flask server startup error.

  • Open your web browser.
  • Navigate to http://localhost:5000.

You should see the DevLens interface with its dark theme, the Code Review tab selected, and a code input area.

Time to test the Code Review mode with a Python snippet that has deliberate bugs.

  • Select Python from the Language dropdown.
  • Copy the sample code below and paste it into the Paste your code textarea:
def login(username, password):
    admin_password = "secret123"
    query = "SELECT * FROM users WHERE name = '" + username + "'"
    result = eval(query)
    print(password)
    return result

Why this sample code?

This snippet is intentionally packed with security issues: a hardcoded password, SQL injection via string concatenation, eval() on untrusted input, and a password printed to the console. The AI should catch all of them.

  • Click the Review Code button.

A loading spinner appears and the button turns gray while the AI analyzes the code. After a few seconds, findings appear below with colored severity badges, descriptions, and fix suggestions.

What do the severity colors mean?

  • Red (critical): serious bugs or security vulnerabilities that need immediate attention.
  • Yellow (warning): potential issues or bad practices that could cause problems down the line.
  • Blue (info): style suggestions and minor improvements.

Now test the Error Explainer mode with a real Python error.

  • Click the Error Explainer tab at the top.
  • Paste this error message into the Error message textarea:
TypeError: can only concatenate str (not "int") to str
  • Paste this code into the Your code textarea:
age = 25
greeting = "Hello, I am " + age + " years old."
print(greeting)
  • Click the Explain Error button.

An explanation card appears with the error type as a heading, a plain-English explanation, the problematic code highlighted with a red border, the corrected code highlighted with a green border, and a prevention tip.

What if the AI response looks different from expected?

AI responses can vary between runs. You might see slightly different descriptions, severity levels, or wordings. The important thing is that the results are structured with severity badges and fix suggestions (Code Review) or error type, explanation, and corrected code (Error Explainer).

🙋‍♀️ Not seeing any results?

  • Check your terminal for error messages. If you see an AI service error, your API key might be invalid or the Gemini free tier rate limit might be exceeded. Wait a minute and try again.
  • If you see a network error in the browser, make sure your Flask server is still running in the terminal. Do not close the terminal window while testing.
  • If results appear but look like raw text instead of formatted cards, open your browser's developer console (F12) and check for JavaScript errors.

Help me debug why DevLens isn't displaying results.

Your full-stack AI developer tool is working. Both modes analyze real code and produce structured, formatted results. Ready for a bonus challenge? The Secret Mission adds persistent review history so you can revisit past analyses.

Secret mission

Add a Review History Feature

Your DevLens tool works great for one-off reviews. But what happens when you want to revisit an analysis you ran earlier today? In this secret mission, you will add a persistent history feature that saves every review and explanation to localStorage and lets you reload them with a single click.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. The Gemini API key you created is on the permanent free tier with no billing account required, so there are no ongoing costs.

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

Resources you used:

  • The Flask development server running at http://localhost:5000.
  • The devlens project folder on your Desktop (includes all project files, the virtual environment, and your .env file).
  • Your Gemini API key in Google AI Studio.
  • Browser localStorage data saved by the review history feature.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with DevLens.

  • Your Flask server stays running in the terminal. You can keep sending code reviews and error explanations.
  • Your Gemini API key remains active on the free tier. There is no charge for leaving it in place.
  • Your review history continues to accumulate in browser localStorage.

Pause - I'll come back to this later

Shut down running processes to free up your terminal, but keep all files so you can pick up where you left off.

  • Switch to the terminal where Flask is running and press Ctrl+C to stop the server.
  • Your project folder, virtual environment, and .env file all stay on disk. When you are ready to return, activate the virtual environment and run python app.py again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each item below.

Stop the Flask server
  • Switch to the terminal where Flask is running and press Ctrl+C to stop it.
  • If your virtual environment is still active, deactivate it by running:
deactivate
Delete the project folder

This removes all project files, the virtual environment, and the .env file containing your API key reference.

  • On macOS or Linux, run:
rm -rf ~/Desktop/devlens
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\devlens
Delete your Gemini API key
  • Go to aistudio.google.com and sign in with your Google account.
  • Open the API keys page where you originally created your key.
  • Find the key you used for this project and delete it.

Is leaving the key a problem?

The Gemini API key is on a permanent free tier with no billing account attached, so there is no cost risk in leaving it. However, deleting unused API keys is a good security habit.

Clear browser localStorage
  • The review history feature saved data to your browser's localStorage under the key devlens_history. This data is tied to localhost:5000 and becomes harmless once the server is deleted.
  • To remove it manually, open your browser's Developer Tools, go to the Application tab (Chrome) or Storage tab (Firefox), find localStorage for localhost:5000, and clear it.

Nice Work!

Nice Work!

You just built DevLens, a full-stack AI-powered developer tool with two working modes: Code Review and Error Explainer. That is a serious addition to your toolkit.

You learned how to:

  • Build a full-stack application with a Flask backend and vanilla JavaScript frontend, served from a single command.
  • Integrate the Google Gemini API using the official Google GenAI SDK with crafted system prompts that produce structured, parseable JSON responses.
  • Design two REST API endpoints (/api/review and /api/explain) and a polished, tabbed dark-theme frontend that displays AI analysis with severity badges and loading states.
  • Secret Mission: Added persistent review history using localStorage with save, load, and clear functionality.

Ready to quiz yourself?