AI Mock Interview Coach with FastAPI

Build a full-stack AI interview coach with speech-to-text and scoring.

Introduction

30 Second Summary

Job interviews are stressful, and the best way to get better at them is practice. But finding someone to run mock interviews with you on demand is nearly impossible.

In this project, you will build a full-stack AI-powered mock interview coach that runs in your browser. You pick a job role, answer questions by typing or uploading audio, and get real-time scores and a final performance report.

What You'll Build

A local web application with a FastAPI backend and an HTML/CSS/JS frontend that uses Groq's free Llama 3.3 70B model to generate interview questions and evaluate your answers, plus Whisper for speech-to-text transcription of audio answers.

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

  • A FastAPI backend with API endpoints for starting interviews, evaluating answers, and generating session reports.
  • Groq-powered LLM integration that generates tailored questions, scores your answers on clarity, relevance, depth, and confidence, and returns structured JSON feedback.
  • A chat-style HTML frontend with text input, audio file upload, real-time scoring, and a final performance report with coaching tips.
  • A secret mission where you add difficulty progression so the AI adjusts question complexity based on your scores.

Are there any prerequisites?

You need basic Python knowledge (variables, functions, loops). No prior FastAPI or LLM experience is needed. Everything in this project is free, including the Groq API. No credit card required.

Before We Start

Before We Start...

Set Up Your Project and Groq API Key

Before you can build an AI interview coach, you need a project structure and access to an AI model. Your app will use Groq's free API to power both the interviewer brain (Llama 3.3 70B) and speech-to-text (Whisper). In this step, you'll create the project folder, install all the dependencies, and grab your free Groq API key.

In This Step, Get Ready To:
  • Create the project folder structure and a Python virtual environment.
  • Install FastAPI, Groq SDK, and all required packages.
  • Sign up for a free Groq account and configure your API key.
Create the Project Structure and Virtual Environment

Your project needs a clear folder layout so FastAPI knows where to find your HTML templates and static files like CSS and JavaScript. You'll also create a virtual environment to keep this project's packages isolated from the rest of your system.

  • Open VS Code and open a terminal.
  • Run the following commands to create your project folder and its subfolders:
mkdir interview-coach
cd interview-coach
mkdir templates static

What do these folders do?

The templates/ folder holds your HTML files. FastAPI uses a templating engine called Jinja2 to serve these pages. The static/ folder holds your CSS and JavaScript files that the browser loads directly.

  • Create the main application file:

macOS

touch main.py

Windows

New-Item main.py
  • Create a Python virtual environment:
python3 -m venv venv
  • Activate the virtual environment:

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.

Getting a 'python3 not found' error?

On Windows, try using python instead of python3. If Python isn't installed at all, download it from python.org. Help me troubleshoot my Python installation.

  • Open the interview-coach folder in VS Code by clicking File, then Open Folder, and selecting the interview-coach directory.

Your project folder should now look like this:

interview-coach/
├── main.py
├── static/
├── templates/
└── venv/
Install Dependencies

Your project needs several Python packages to work. One single pip install command grabs everything.

  • Make sure your virtual environment is active (you see (venv) in your terminal), then run:
pip install fastapi[standard] groq python-multipart python-dotenv jinja2

What does each package do?

  • fastapi[standard] installs FastAPI along with Uvicorn (the web server that runs your app) and Jinja2.
  • groq is the Groq Python SDK for calling the Llama and Whisper models.
  • python-multipart enables file uploads, which you'll need for audio answers.
  • python-dotenv loads your API key from a .env file so you don't hardcode secrets in your code.

I see 'Successfully installed'

You're all set. All packages are installed in your virtual environment.

I see an error

If you see a permissions error, make sure your virtual environment is active. You should see (venv) in your terminal prompt. If not, re-run the activation command from the previous substep. Help me fix my pip install error.

Get Your Free Groq API Key

Groq provides free access to both the Llama 3.3 70B language model and the Whisper Large V3 speech-to-text model. No credit card is required. You just need an API key to authenticate your requests.

Why Groq Instead of OpenAI or Gemini?

Groq is the only provider that offers both a free LLM and free speech-to-text with no credit card. OpenAI requires payment for both. Google Gemini has a free LLM tier but no free speech-to-text.

Groq's API is also OpenAI-compatible, so the code patterns you learn here transfer directly if you switch to OpenAI later.

  • Open console.groq.com in your browser.
  • Sign up using your Google or GitHub account.
  • Once logged in, navigate to API Keys in the left sidebar.
  • Click Create API Key.
  • Give it a name like interview-coach and click Submit.
  • Copy your API key. It starts with gsk_. Save it somewhere safe because you won't be able to see it again.

Now you need to store this key in a .env file so your Python code can access it without hardcoding it.

  • Create a file called .env in the root of your interview-coach folder.
  • Add your API key to the file:
GROQ_API_KEY=your-api-key-here

Replace the placeholder with your actual key

Replace your-api-key-here with the actual API key you copied from Groq. It should look something like gsk_abc123.... Never share this key or commit it to version control.

To prevent accidentally pushing your API key to GitHub, create a .gitignore file.

  • Create a file called .gitignore in your project root and add the following:
.env
venv/
__pycache__/

What is a .gitignore file?

The .gitignore file tells Git to ignore certain files and folders. This keeps your API key (.env), your virtual environment (venv/), and Python cache files (__pycache__/) out of version control.

Your final project structure should look like this:

interview-coach/
├── .env
├── .gitignore
├── main.py
├── static/
├── templates/
└── venv/

Your project is structured and your Groq API key is ready to go. Next up, you'll build the FastAPI backend and connect it to Groq's Llama 3.3 70B model to generate interview questions and evaluate answers.

Build the FastAPI Backend with LLM Integration

Your interview coach needs a brain. In this step, you'll build the FastAPI backend that connects to Groq's Llama 3.3 70B model to generate interview questions and evaluate answers with structured scores.

Every time a user starts an interview or submits an answer, your backend sends a carefully crafted prompt to the LLM and parses the response. The quality of your prompts directly controls the quality of the interview experience.

In This Step, Get Ready To:
  • Set up the FastAPI app with Groq client initialization and Pydantic models.
  • Build an endpoint that generates tailored interview questions using the LLM.
  • Build an endpoint that evaluates answers and returns structured JSON scores.
Create the FastAPI App with Groq Client Initialization

Before you can call any AI models, you need to set up the foundation. FastAPI is your web framework. The Groq client is how your Python code talks to the LLM. Pydantic models define the shape of data flowing through your app, so FastAPI can validate requests automatically.

  • Open main.py in VS Code.
  • Add the following code to set up your imports, FastAPI app, Groq client, and data models:
import os
import json
from fastapi import FastAPI, Request, UploadFile, File, Form
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from dotenv import load_dotenv
from groq import Groq

# Load environment variables from .env file
load_dotenv()

# Initialize the FastAPI app
app = FastAPI()

# Set up Jinja2 templates and static files
templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")

# Initialize the Groq client with your API key
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))

# Define the data model for starting an interview
class InterviewRequest(BaseModel):
    job_role: str

# Define the data model for submitting an answer
class AnswerRequest(BaseModel):
    question: str
    answer: str
    job_role: str

What does this code do?

This code sets up three things. First, the FastAPI app itself, configured to serve HTML templates from the templates/ folder and static files (CSS, JS) from the static/ folder.

Second, the Groq client reads your API key from the .env file so your code never contains hardcoded secrets.

Third, the Pydantic models (InterviewRequest and AnswerRequest) tell FastAPI exactly what fields to expect in incoming requests. If a request is missing a field, FastAPI automatically returns a helpful error.

What is a Pydantic Model?

A Pydantic model is a Python class that defines the expected shape of incoming data. When you declare job_role: str, Pydantic ensures the request includes a job_role field and that it is a string. No manual validation code needed.

Build the Interview Question Generation Endpoint

When a user picks a job role and clicks "Start Interview," your backend needs to ask the LLM to generate a relevant question. This is where prompt engineering comes in. The system prompt tells the LLM who it is and how to behave. The user message tells it what to do right now.

  • Add the following endpoint to the bottom of main.py:
@app.post("/api/start-interview")
async def start_interview(request: InterviewRequest):
    """Generate an interview question for the given job role."""

    # System prompt tells the LLM how to behave
    system_prompt = (
        f"You are a professional job interviewer conducting a mock interview "
        f"for a {request.job_role} position. Ask one clear, specific interview "
        f"question. Do not include any preamble or extra text. Just ask the question."
    )

    # Call Groq's Llama 3.3 70B model
    chat_completion = client.chat.completions.create(
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Ask me an interview question for a {request.job_role} role."}
        ],
        model="llama-3.3-70b-versatile",
        temperature=0.7,
    )

    # Extract the question from the response
    question = chat_completion.choices[0].message.content

    return {"question": question}

What does this endpoint do?

When a POST request hits /api/start-interview with a job_role field, FastAPI validates the request body against your InterviewRequest model. Then it sends two messages to the LLM: a system message defining the interviewer persona, and a user message requesting a question.

The temperature=0.7 setting controls creativity. A value of 0.7 gives varied but sensible questions. Lower values (like 0.2) would make the LLM more predictable. Higher values (like 1.0) would make it more random.

Pro tip: test your prompts

The system prompt is the most important part of your LLM integration. If questions feel too generic, make the prompt more specific. For example, you could add "Focus on technical skills and real-world scenarios" to get better questions.

Build the Answer Evaluation Endpoint

Generating questions is only half the job. The real value of your interview coach comes from evaluating answers. You need the LLM to return structured data, not just free-form text. This means your prompt must instruct the LLM to respond in a specific JSON format with scores and feedback.

  • Add the answer evaluation endpoint to the bottom of main.py:
@app.post("/api/submit-answer")
async def submit_answer(request: AnswerRequest):
    """Evaluate the user's answer and return scores plus feedback."""

    # Prompt the LLM to return structured JSON scores
    system_prompt = (
        f"You are a professional interviewer evaluating a candidate's answer "
        f"for a {request.job_role} position. "
        f"Evaluate the answer and respond ONLY with valid JSON in this exact format:\n"
        f'{{{\n'
        f'  "scores": {{{\n'
        f'    "clarity": <1-10>,\n'
        f'    "relevance": <1-10>,\n'
        f'    "depth": <1-10>,\n'
        f'    "confidence": <1-10>\n'
        f'  }}}},\n'
        f'  "overall_score": <1-10>,\n'
        f'  "feedback": "<2-3 sentences of constructive feedback>",\n'
        f'  "follow_up_question": "<a follow-up interview question>"\n'
        f'}}}}'
    )

    user_message = (
        f"Question: {request.question}\n\n"
        f"Candidate's Answer: {request.answer}\n\n"
        f"Evaluate this answer."
    )

    chat_completion = client.chat.completions.create(
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_message}
        ],
        model="llama-3.3-70b-versatile",
        temperature=0.3,
    )

    response_text = chat_completion.choices[0].message.content

    # Parse the JSON response from the LLM
    try:
        evaluation = json.loads(response_text)
    except json.JSONDecodeError:
        # If the LLM didn't return valid JSON, return a fallback
        evaluation = {
            "scores": {"clarity": 5, "relevance": 5, "depth": 5, "confidence": 5},
            "overall_score": 5,
            "feedback": "Could not parse evaluation. Please try again.",
            "follow_up_question": "Could you elaborate on your answer?"
        }

    return evaluation

What does this endpoint do?

This endpoint receives the original question, the user's answer, and the job role. It sends all three to the LLM with a system prompt that demands a specific JSON structure. The LLM returns scores for clarity, relevance, depth, and confidence (each 1-10), written feedback, and a follow-up question.

Notice the temperature=0.3 here. Evaluation needs to be more consistent and less creative than question generation, so a lower temperature keeps the scores more reliable.

Why is there a try/except block?

LLMs don't always follow instructions perfectly. Sometimes the model wraps its JSON in markdown code fences or adds extra text. The try/except block catches these cases and returns sensible fallback scores instead of crashing your app. This is a defensive programming pattern you'll use often when working with LLMs.

What is Structured JSON Output from an LLM?

By default, LLMs return free-form text. Structured JSON output means instructing the LLM (through your prompt) to respond in a specific JSON format. This lets your code parse the response programmatically with json.loads() and use the scores and feedback as real data.

Your backend now has two powerful endpoints: one that generates interview questions and one that evaluates answers with structured scores. Next up, you'll build the frontend so you can actually see this in action in your browser.

Build the Frontend Interview Page

Your backend can now generate interview questions and evaluate answers. But right now, the only way to interact with it is through raw API calls. In this step, you'll build the frontend that brings your interview coach to life. You'll create an HTML page with a chat-style interface, add JavaScript to handle the interview flow, and wire everything together so you can run a real interview session in your browser.

In This Step, Get Ready To:
  • Create an HTML template with a chat-style interview interface and CSS styling.
  • Add JavaScript to manage the interview flow and display real-time feedback.
  • Serve the frontend from FastAPI and test a full text-based interview session.
Create the HTML Template and CSS

FastAPI serves HTML pages using Jinja2 templates. You already configured the templates/ directory and static/ directory in Step 2. Now you'll create the actual files that make up the interview interface.

  • Create a file called index.html inside your templates/ folder.
  • Paste in the following HTML:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI Interview Coach</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <div class="container">
        <h1>AI Interview Coach</h1>

        <!-- Role selection area -->
        <div id="setup-area">
            <label for="role-select">Choose a job role:</label>
            <select id="role-select">
                <option value="Software Engineer">Software Engineer</option>
                <option value="Data Scientist">Data Scientist</option>
                <option value="Product Manager">Product Manager</option>
                <option value="DevOps Engineer">DevOps Engineer</option>
                <option value="UX Designer">UX Designer</option>
            </select>
            <button id="start-btn" onclick="startInterview()">Start Interview</button>
        </div>

        <!-- Chat-style message area -->
        <div id="chat-area" class="hidden">
            <div id="messages"></div>

            <!-- Answer input area -->
            <div id="input-area">
                <textarea id="answer-input" placeholder="Type your answer here..."></textarea>
                <div id="input-controls">
                    <label for="audio-upload" class="upload-label">Upload Audio</label>
                    <input type="file" id="audio-upload" accept=".mp3,.wav,.m4a,.webm,.ogg,.mp4" class="hidden">
                    <button id="submit-btn" onclick="submitAnswer()">Submit Answer</button>
                </div>
            </div>

            <button id="end-btn" onclick="endInterview()" class="hidden">End Interview</button>
        </div>

        <!-- Summary area -->
        <div id="summary-area" class="hidden"></div>
    </div>

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

What is this HTML doing?

The page has three main sections. The setup-area shows a dropdown to pick a job role and a button to start. The chat-area is where the interview conversation happens, with a text input and audio upload button. The summary-area stays hidden until you end the interview and view your report.

The hidden class controls which section is visible at any time. JavaScript toggles these classes as the interview progresses.

  • Create a file called style.css inside your static/ folder.
  • Paste in the following CSS:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background-color: #f0f2f5;
    color: #1a1a1a;
}

.container {
    max-width: 700px;
    margin: 40px auto;
    padding: 20px;
}

h1 {
    text-align: center;
    margin-bottom: 24px;
    color: #2d3748;
}

/* Setup area */
#setup-area {
    text-align: center;
    padding: 32px;
    background: white;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}

#setup-area label {
    font-size: 18px;
    display: block;
    margin-bottom: 12px;
}

#role-select {
    padding: 10px 16px;
    font-size: 16px;
    border-radius: 8px;
    border: 1px solid #cbd5e0;
    margin-bottom: 16px;
}

#start-btn {
    display: block;
    margin: 0 auto;
    padding: 12px 32px;
    font-size: 16px;
    background: #3182ce;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
}

#start-btn:hover {
    background: #2b6cb0;
}

/* Chat area */
#messages {
    background: white;
    border-radius: 12px;
    padding: 20px;
    min-height: 300px;
    max-height: 500px;
    overflow-y: auto;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
    margin-bottom: 16px;
}

.message {
    margin-bottom: 16px;
    padding: 12px 16px;
    border-radius: 10px;
    max-width: 85%;
    line-height: 1.5;
}

.message.interviewer {
    background: #ebf4ff;
    color: #2b6cb0;
    margin-right: auto;
}

.message.user {
    background: #f0fff4;
    color: #276749;
    margin-left: auto;
}

.message.feedback {
    background: #fffff0;
    border: 1px solid #ecc94b;
    max-width: 100%;
}

.score-card {
    display: flex;
    gap: 12px;
    flex-wrap: wrap;
    margin-top: 8px;
}

.score-item {
    background: white;
    padding: 6px 12px;
    border-radius: 6px;
    font-size: 14px;
    border: 1px solid #e2e8f0;
}

/* Input area */
#input-area {
    background: white;
    border-radius: 12px;
    padding: 16px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}

#answer-input {
    width: 100%;
    min-height: 80px;
    padding: 12px;
    font-size: 15px;
    border: 1px solid #cbd5e0;
    border-radius: 8px;
    resize: vertical;
    font-family: inherit;
}

#input-controls {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 10px;
}

.upload-label {
    padding: 8px 16px;
    background: #edf2f7;
    border-radius: 6px;
    cursor: pointer;
    font-size: 14px;
}

.upload-label:hover {
    background: #e2e8f0;
}

#submit-btn {
    padding: 10px 24px;
    background: #38a169;
    color: white;
    border: none;
    border-radius: 8px;
    font-size: 15px;
    cursor: pointer;
}

#submit-btn:hover {
    background: #2f855a;
}

#end-btn {
    display: block;
    margin: 16px auto 0;
    padding: 10px 24px;
    background: #e53e3e;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
}

#end-btn:hover {
    background: #c53030;
}

/* Summary area */
#summary-area {
    background: white;
    border-radius: 12px;
    padding: 24px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}

.hidden {
    display: none;
}

.loading {
    text-align: center;
    color: #718096;
    padding: 12px;
    font-style: italic;
}

What does this CSS do?

The stylesheet creates a centered card layout with a maximum width of 700px. Messages from the interviewer appear on the left in blue. Your answers appear on the right in green. Feedback cards appear full-width in yellow. The .hidden class is what JavaScript uses to show and hide sections as the interview progresses.

Add JavaScript for the Interview Flow

The HTML is just a shell. JavaScript is what makes the interview interactive. It calls your FastAPI endpoints, displays questions and feedback, and tracks the current question so the evaluation endpoint knows what was asked.

  • Create a file called app.js inside your static/ folder.
  • Paste in the following JavaScript:
// Track the current interview state
let currentQuestion = '';
let questionCount = 0;

// Start a new interview session
async function startInterview() {
    const role = document.getElementById('role-select').value;

    // Hide setup, show chat area
    document.getElementById('setup-area').classList.add('hidden');
    document.getElementById('chat-area').classList.remove('hidden');
    document.getElementById('messages').innerHTML = '';

    addMessage('Starting interview for: ' + role + '...', 'interviewer');

    try {
        // Call the backend to get the first question
        const response = await fetch('/api/start-interview', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ role: role })
        });
        const data = await response.json();

        // Display the AI's first question
        currentQuestion = data.question;
        questionCount = 1;
        addMessage(data.question, 'interviewer');

        // Show the End Interview button after the first question
        document.getElementById('end-btn').classList.remove('hidden');
    } catch (error) {
        addMessage('Error starting interview. Please try again.', 'feedback');
        console.error(error);
    }
}

// Submit a text answer for evaluation
async function submitAnswer() {
    const answerInput = document.getElementById('answer-input');
    const answer = answerInput.value.trim();

    if (!answer) return;

    // Display the user's answer in the chat
    addMessage(answer, 'user');
    answerInput.value = '';

    // Show a loading indicator while the AI evaluates
    addMessage('Evaluating your answer...', 'loading');

    try {
        // Send the answer and current question to the backend
        const response = await fetch('/api/submit-answer', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                question: currentQuestion,
                answer: answer
            })
        });
        const data = await response.json();

        // Remove the loading message
        removeLoading();

        // Display the feedback and scores
        showFeedback(data);

        // Display the follow-up question
        if (data.follow_up_question) {
            currentQuestion = data.follow_up_question;
            questionCount++;
            addMessage(data.follow_up_question, 'interviewer');
        }
    } catch (error) {
        removeLoading();
        addMessage('Error evaluating answer. Please try again.', 'feedback');
        console.error(error);
    }
}

// Add a message bubble to the chat area
function addMessage(text, type) {
    const messagesDiv = document.getElementById('messages');
    const messageEl = document.createElement('div');
    messageEl.className = 'message ' + type;
    messageEl.textContent = text;
    messagesDiv.appendChild(messageEl);

    // Auto-scroll to the latest message
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

// Display feedback with score cards
function showFeedback(data) {
    const messagesDiv = document.getElementById('messages');
    const feedbackEl = document.createElement('div');
    feedbackEl.className = 'message feedback';

    // Build the feedback HTML with scores
    let html = '<strong>Feedback:</strong> ' + data.feedback + '<br>';
    html += '<div class="score-card">';
    html += '<span class="score-item">Clarity: ' + data.scores.clarity + '/10</span>';
    html += '<span class="score-item">Relevance: ' + data.scores.relevance + '/10</span>';
    html += '<span class="score-item">Depth: ' + data.scores.depth + '/10</span>';
    html += '<span class="score-item">Confidence: ' + data.scores.confidence + '/10</span>';
    html += '</div>';

    feedbackEl.innerHTML = html;
    messagesDiv.appendChild(feedbackEl);
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

// Remove the loading indicator
function removeLoading() {
    const loadingMsg = document.querySelector('.message.loading');
    if (loadingMsg) loadingMsg.remove();
}

// Placeholder for ending the interview (built in Step 5)
async function endInterview() {
    addMessage('End interview feature coming in Step 5!', 'feedback');
}

How does the JavaScript work?

The startInterview() function calls your /api/start-interview endpoint with the selected role and displays the first question. The submitAnswer() function sends your typed answer to /api/submit-answer along with the current question, then displays the scores and follow-up question.

The currentQuestion variable tracks which question is being answered. Each time the AI returns a follow-up question, this variable updates so the next submission sends the correct context.

Wire Up the FastAPI Route and Test

Your frontend files are ready, but FastAPI doesn't know to serve them yet. You need a route that returns the HTML template when someone visits the root URL.

  • Open main.py and add the following route:
from fastapi import Request

@app.get("/")
async def home(request: Request):
    # Render the index.html template and pass the request object
    return templates.TemplateResponse("index.html", {"request": request})

Why does TemplateResponse need a request object?

Jinja2's TemplateResponse() requires the request object in its context dictionary. This is a FastAPI requirement. The template engine uses the request to resolve URLs and other request-specific information.

Now test the complete flow.

  • Start (or restart) your server:
uvicorn main:app --reload
  • Open your browser and go to http://localhost:8000.
  • Select a job role from the dropdown (e.g. Software Engineer).
  • Click Start Interview.

The AI should respond with an interview question in the chat area. The question appears in a blue message bubble on the left side.

  • Type an answer in the text area and click Submit Answer.

Your answer appears in a green bubble on the right. After a moment, a yellow feedback card appears with scores for clarity, relevance, depth, and confidence. A follow-up question then appears below.

What should I see?

You should see a clean chat interface with alternating message bubbles. Interviewer questions appear in blue on the left. Your answers appear in green on the right. Feedback cards appear in yellow with four score badges (Clarity, Relevance, Depth, Confidence) each rated out of 10.

Page loads but nothing happens when you click Start Interview?

Open your browser's developer console (press F12 or Cmd+Option+I on macOS) and check for JavaScript errors. Common issues include the app.js file not being found (check that it is inside the static/ folder) or a 404 error on the API endpoint (check that your /api/start-interview route is defined in main.py).

If you need more help, help me debug my frontend.

Your interview coach now has a working frontend. You can start an interview, answer questions, and see real-time feedback with scores. Next up, you'll add audio answer support so you can speak your answers instead of typing them.

Add Audio Answer Support with Whisper

Your interview coach can already ask questions and evaluate typed answers. But real interviews are spoken, not typed. In this step, you'll add audio answer support so users can upload a voice recording and have it automatically transcribed and evaluated.

Groq provides free access to Whisper Large V3, a powerful speech-to-text model. You'll build a transcription endpoint and wire it into the frontend so audio files flow seamlessly through the same evaluation pipeline as text answers.

In This Step, Get Ready To:
  • Build a transcription endpoint that converts audio files to text using Groq's Whisper model.
  • Connect the audio upload button to the transcription and evaluation flow in the frontend.
Build the Audio Transcription Endpoint

When a user uploads an audio file, your backend needs to receive it, send it to Groq's Whisper model, and return the transcribed text. FastAPI makes file uploads easy with its UploadFile class, which streams the file contents without loading everything into memory at once.

  • Open main.py and add the following imports at the top of the file if they are not already present.
from fastapi import UploadFile, File, HTTPException
  • Add the transcription endpoint below your existing routes in main.py.
ALLOWED_AUDIO_FORMATS = {"mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm"}

@app.post("/api/transcribe")
async def transcribe_audio(file: UploadFile = File(...)):
    # Validate the file extension
    file_ext = file.filename.split(".")[-1].lower()
    if file_ext not in ALLOWED_AUDIO_FORMATS:
        raise HTTPException(
            status_code=400,
            detail=f"Unsupported audio format: {file_ext}. Supported formats: {', '.join(ALLOWED_AUDIO_FORMATS)}"
        )

    # Read the uploaded file contents
    audio_data = await file.read()

    # Send to Groq's Whisper model for transcription
    transcription = client.audio.transcriptions.create(
        file=(file.filename, audio_data),
        model="whisper-large-v3-turbo",
        response_format="verbose_json"
    )

    return {"text": transcription.text}

What Does This Code Do?

This endpoint does three things. First, it checks that the uploaded file is a supported audio format. Groq's Whisper model accepts mp3, mp4, mpeg, mpga, m4a, ogg, wav, and webm files.

Next, it reads the raw audio bytes from the upload. Finally, it sends those bytes to Groq's whisper-large-v3-turbo model and returns the transcribed text. The file= parameter takes a tuple of the filename and the file data.

What is Whisper?

Whisper is an open-source speech-to-text model originally created by OpenAI. It can transcribe audio in dozens of languages with high accuracy. Groq hosts Whisper on their fast inference hardware, so you get free, fast transcriptions through the same API client you already use for the LLM.

  • Save main.py.

If your server is still running with --reload, it picks up the changes automatically. You can test this endpoint by uploading an audio file through the frontend once you wire up the JavaScript in the next substep.

Connect Audio Upload to the Evaluation Flow

The backend can now transcribe audio, but the frontend does not know how to use it yet. You need to update the JavaScript so that when a user uploads an audio file, it first transcribes the audio, then sends the resulting text through the same answer evaluation flow.

  • Open static/app.js and add a function to handle audio file uploads.
async function handleAudioUpload(file) {
    // Show a loading message while transcription runs
    addMessage("Transcribing your audio...", "system");
    showLoading(true);

    // Send the audio file to the transcription endpoint
    const formData = new FormData();
    formData.append("file", file);

    try {
        const response = await fetch("/api/transcribe", {
            method: "POST",
            body: formData
        });

        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.detail || "Transcription failed");
        }

        const data = await response.json();
        const transcribedText = data.text;

        // Display the transcribed text in the chat
        addMessage(transcribedText, "user");

        // Send the transcribed text for evaluation
        await submitAnswer(transcribedText);
    } catch (error) {
        addMessage("Error: " + error.message, "system");
    } finally {
        showLoading(false);
    }
}

What Does This Function Do?

This function handles the full audio-to-evaluation pipeline. It wraps the audio file in a FormData object (which is how browsers send file uploads), posts it to your /api/transcribe endpoint, and then passes the transcribed text to the existing submitAnswer() function. The user sees their spoken words appear in the chat before the AI evaluates them.

  • Add an event listener for the audio file input. This connects the upload button you created in Step 3 to the new handleAudioUpload function.
// Connect the file upload input to the audio handler
const audioInput = document.getElementById("audio-upload");
audioInput.addEventListener("change", (event) => {
    const file = event.target.files[0];
    if (file) {
        handleAudioUpload(file);
        // Reset the input so the same file can be uploaded again
        audioInput.value = "";
    }
});
  • Add a helper function to show and hide a loading indicator while the transcription is in progress.
function showLoading(isLoading) {
    const submitBtn = document.getElementById("submit-btn");
    const audioBtn = document.getElementById("audio-upload");
    // Disable inputs while processing
    submitBtn.disabled = isLoading;
    audioBtn.disabled = isLoading;
    submitBtn.textContent = isLoading ? "Processing..." : "Submit";
}

Pro tip: test with a short recording

You can record a quick audio clip on your phone or computer using any voice recorder app. Save it as an mp3 or wav file and upload it through the browser. A 10-15 second clip is plenty for testing.

  • Save static/app.js.
  • Open your browser to http://localhost:8000 and start an interview.
  • Click the audio upload button and select an audio file from your computer.

You should see a "Transcribing your audio..." message appear, followed by your spoken words displayed as text in the chat. The AI then evaluates your transcribed answer and returns scores and feedback, just like it does for typed answers.

Getting a 400 Error on Upload?

Make sure your audio file is in one of the supported formats: mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm. If you recorded on an iPhone, the default format is m4a, which is supported. Also check that the python-multipart package is installed. Without it, FastAPI cannot process file uploads.

Still stuck? Help me debug my audio upload error.

Your interview coach now supports both typed and spoken answers. Next up, you'll build a session summary that pulls together all your scores into a comprehensive performance report.

Build the Session Summary and Performance Report

Your interview coach can ask questions, evaluate answers, and even transcribe audio recordings. But right now, each answer exists in isolation. The learner has no way to see how they performed across the entire session.

In this step, you'll add session tracking so every question, answer, and score is remembered. Then you'll build a summary endpoint that sends the full session history to the LLM for a comprehensive performance report. Finally, you'll wire up the frontend to display that report with an overall score, strengths, weaknesses, and coaching tips.

In This Step, Get Ready To:
  • Add in-memory session storage to track all questions, answers, and scores.
  • Build a summary endpoint that generates a full performance report using the LLM.
  • Display the performance report in a styled summary card on the frontend.
Track Session State and Build the Summary Endpoint

Right now, your app forgets everything after each API call. To generate a meaningful performance report, the backend needs to remember every question it asked, every answer the user gave, and every score it assigned. You'll store this in a simple Python dictionary that lives in memory.

  • Open main.py in VS Code.
  • Add an in-memory session storage dictionary near the top of the file, after your Groq client initialization.
# In-memory session storage
# Each session tracks the job role, and lists of questions, answers, and scores
session_data = {
    "role": "",
    "history": []  # Each entry: {"question": str, "answer": str, "scores": dict, "feedback": str}
}

Why a Simple Dictionary Instead of a Database?

For a learning project, an in-memory dictionary keeps things simple. You don't need to install or configure a database. The trade-off is that your session data disappears when the server restarts. For a production app, you'd swap this out for a database like SQLite or PostgreSQL.

Now you need to update your existing endpoints to actually save data into this session storage.

  • Update your /api/start-interview endpoint to reset the session and store the selected role.
@app.post("/api/start-interview")
async def start_interview(request: Request):
    data = await request.json()
    role = data.get("role", "Software Engineer")

    # Reset session for new interview
    session_data["role"] = role
    session_data["history"] = []

    # Generate the first question (your existing LLM call)
    response = client.chat.completions.create(
        messages=[
            {"role": "system", "content": f"You are a professional interviewer for the role of {role}. Ask one clear, specific interview question."},
            {"role": "user", "content": "Start the interview with your first question."}
        ],
        model="llama-3.3-70b-versatile"
    )

    question = response.choices[0].message.content
    return {"question": question}
  • Update your /api/submit-answer endpoint to save each question-answer-score entry to the session history.
@app.post("/api/submit-answer")
async def submit_answer(request: Request):
    data = await request.json()
    question = data.get("question", "")
    answer = data.get("answer", "")
    role = session_data["role"]

    # Ask the LLM to evaluate and return structured JSON
    eval_prompt = f"""You are evaluating an interview answer for a {role} position.

Question: {question}
Answer: {answer}

Respond ONLY with valid JSON in this exact format:
{{
    "scores": {{
        "clarity": <1-10>,
        "relevance": <1-10>,
        "depth": <1-10>,
        "confidence": <1-10>
    }},
    "feedback": "<2-3 sentences of constructive feedback>",
    "follow_up_question": "<next interview question>"
}}"""

    response = client.chat.completions.create(
        messages=[
            {"role": "system", "content": "You are a professional interview evaluator. Always respond with valid JSON only."},
            {"role": "user", "content": eval_prompt}
        ],
        model="llama-3.3-70b-versatile"
    )

    import json
    result = json.loads(response.choices[0].message.content)

    # Save this exchange to session history
    session_data["history"].append({
        "question": question,
        "answer": answer,
        "scores": result["scores"],
        "feedback": result["feedback"]
    })

    return result

What Does This Change?

The key addition is the session_data["history"].append() call at the end. Every time the user submits an answer, the question, answer, scores, and feedback all get saved. When the interview ends, you'll have a complete record to send to the LLM for the final report.

Now build the endpoint that generates the performance report.

  • Add the following /api/end-interview endpoint to main.py.
@app.post("/api/end-interview")
async def end_interview():
    if not session_data["history"]:
        return {"error": "No interview data to summarize."}

    role = session_data["role"]
    history = session_data["history"]

    # Build a text summary of the full session for the LLM
    session_text = ""
    for i, entry in enumerate(history, 1):
        session_text += f"\nQ{i}: {entry['question']}\n"
        session_text += f"A{i}: {entry['answer']}\n"
        session_text += f"Scores: Clarity={entry['scores']['clarity']}, Relevance={entry['scores']['relevance']}, Depth={entry['scores']['depth']}, Confidence={entry['scores']['confidence']}\n"
        session_text += f"Feedback: {entry['feedback']}\n"

    # Ask the LLM for a comprehensive performance report
    summary_prompt = f"""You are a career coach reviewing a mock interview for a {role} position.

Here is the full interview session:
{session_text}

Provide a comprehensive performance report as valid JSON in this exact format:
{{
    "overall_score": <1-10>,
    "total_questions": {len(history)},
    "strengths": ["<strength 1>", "<strength 2>", "<strength 3>"],
    "weaknesses": ["<weakness 1>", "<weakness 2>"],
    "tips": ["<actionable tip 1>", "<actionable tip 2>", "<actionable tip 3>"],
    "summary": "<2-3 sentence overall assessment>"
}}"""

    response = client.chat.completions.create(
        messages=[
            {"role": "system", "content": "You are a professional career coach. Always respond with valid JSON only."},
            {"role": "user", "content": summary_prompt}
        ],
        model="llama-3.3-70b-versatile"
    )

    import json
    report = json.loads(response.choices[0].message.content)
    return report

What Does This Endpoint Do?

This endpoint collects the entire interview history and formats it into a single text block. It sends that block to Llama 3.3 70B with a prompt asking it to act as a career coach. The LLM reviews all the questions, answers, and scores together, then returns a structured JSON report with an overall score, strengths, weaknesses, and actionable tips.

Build the Summary Display in the Frontend

The backend can now generate a performance report. You need a way for the user to trigger it and see the results in the browser.

  • Open templates/index.html and add an End Interview button near your existing submit button.
<!-- Add this button next to your existing submit button -->
<button id="end-btn" onclick="endInterview()" style="display:none;">End Interview</button>

<!-- Add this container for the summary report, below the chat area -->
<div id="summary-card" style="display:none;">
    <h2>Performance Report</h2>
    <div id="overall-score"></div>
    <div id="summary-text"></div>
    <h3>Strengths</h3>
    <ul id="strengths-list"></ul>
    <h3>Areas for Improvement</h3>
    <ul id="weaknesses-list"></ul>
    <h3>Coaching Tips</h3>
    <ul id="tips-list"></ul>
</div>

Pro tip: show the button at the right time

The End Interview button starts hidden with display:none. You'll make it visible from JavaScript after the user submits their first answer. This prevents users from ending an interview before it starts.

  • Open static/app.js and add the endInterview() function.
async function endInterview() {
    // Disable the button to prevent double-clicks
    document.getElementById('end-btn').disabled = true;
    document.getElementById('end-btn').textContent = 'Generating Report...';

    try {
        const response = await fetch('/api/end-interview', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' }
        });
        const report = await response.json();

        if (report.error) {
            alert(report.error);
            return;
        }

        // Display the overall score
        document.getElementById('overall-score').textContent =
            `Overall Score: ${report.overall_score}/10`;

        // Display the summary text
        document.getElementById('summary-text').textContent = report.summary;

        // Populate strengths list
        const strengthsList = document.getElementById('strengths-list');
        strengthsList.innerHTML = '';
        report.strengths.forEach(s => {
            const li = document.createElement('li');
            li.textContent = s;
            strengthsList.appendChild(li);
        });

        // Populate weaknesses list
        const weaknessesList = document.getElementById('weaknesses-list');
        weaknessesList.innerHTML = '';
        report.weaknesses.forEach(w => {
            const li = document.createElement('li');
            li.textContent = w;
            weaknessesList.appendChild(li);
        });

        // Populate tips list
        const tipsList = document.getElementById('tips-list');
        tipsList.innerHTML = '';
        report.tips.forEach(t => {
            const li = document.createElement('li');
            li.textContent = t;
            tipsList.appendChild(li);
        });

        // Show the summary card and hide the chat input
        document.getElementById('summary-card').style.display = 'block';
    } catch (err) {
        console.error('Error ending interview:', err);
        alert('Failed to generate report. Please try again.');
    }
}

What Does This Function Do?

The endInterview() function calls your /api/end-interview endpoint, receives the JSON report, and populates the summary card with the overall score, strengths, weaknesses, and coaching tips. It also disables the button while the report is being generated so the user doesn't accidentally trigger multiple requests.

  • Update your existing answer submission function in app.js to show the End Interview button after the first answer is submitted.
// Add this line inside your existing submitAnswer() function,
// right after you receive and display the evaluation response:
document.getElementById('end-btn').style.display = 'inline-block';
  • Add some basic styling for the summary card in static/style.css.
#summary-card {
    background: #f8f9fa;
    border-radius: 12px;
    padding: 24px;
    margin-top: 20px;
    border: 1px solid #dee2e6;
}

#summary-card h2 {
    margin-top: 0;
    color: #212529;
}

#overall-score {
    font-size: 2em;
    font-weight: bold;
    color: #0d6efd;
    margin: 16px 0;
}

#summary-card ul {
    padding-left: 20px;
}

#summary-card li {
    margin-bottom: 8px;
    line-height: 1.5;
}

#end-btn {
    background: #dc3545;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 8px;
    cursor: pointer;
    font-size: 1em;
}

#end-btn:disabled {
    background: #6c757d;
    cursor: not-allowed;
}
Test the Complete Flow End-to-End

Everything is wired up. Time to run through a full interview session from start to finish and verify the entire app works together.

  • Make sure your server is running. If it's not, start it with the following command.
uvicorn main:app --reload
  • Open http://localhost:8000 in your browser.
  • Select a job role and click Start Interview.
  • Answer 3-4 questions by typing your responses. Try mixing in an audio upload if you have a recording available.
  • After each answer, verify that you see scores for clarity, relevance, depth, and confidence along with written feedback.
  • Click the End Interview button.
  • Verify that the performance report appears with an overall score, strengths, weaknesses, and coaching tips.

What Should I See?

After clicking End Interview, the button text changes to "Generating Report..." while the LLM processes your session. After a few seconds, a styled summary card appears below the chat area. It shows your overall score out of 10, a list of your top strengths, areas for improvement, and specific actionable tips.

Getting a JSON Parsing Error?

Sometimes the LLM returns text alongside the JSON, which causes json.loads() to fail. If this happens, check your terminal for the raw response content. Make sure your system prompt includes "Always respond with valid JSON only." If the issue persists, help me debug my JSON parsing error with the Groq LLM response.

Your AI interview coach is fully functional. You've built a complete session flow from start to finish, with per-answer scoring and a comprehensive final report. Next up, there's a secret mission waiting for you that will make your coach even smarter.

Secret mission

Add Difficulty Progression

Your interview coach asks every candidate the same level of questions, whether they're acing it or struggling. Real interviewers adapt. Can you make your AI do the same by dynamically adjusting question difficulty based on the candidate's running score?

Before you go

Clean Up Your Resources

This project runs entirely locally with a free Groq API tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Python virtual environment (venv folder in your project directory).
  • Project files (main.py, templates/, static/, .env).
  • Groq API key (stored in your .env file and on console.groq.com).
  • Groq account (free tier, no credit card attached).
  • Uvicorn dev server (running process in your terminal).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep practicing interviews.

  • Your project files, virtual environment, and Groq API key all stay in place.
  • The Groq free tier has no ongoing charges, so your API key costs nothing to keep.
  • You can restart the app anytime with uvicorn main:app --reload.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your files and API key stay safe for next time.

  • Stop the Uvicorn server by pressing Ctrl + C in the terminal where it is running.
  • Deactivate your virtual environment by running deactivate in your terminal.
  • Your project folder, virtual environment, and .env file will remain on disk.
  • To resume later, navigate to your project folder, activate the virtual environment, and run uvicorn main:app --reload.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop the Uvicorn server by pressing Ctrl + C in the terminal.
  • Deactivate your virtual environment by running deactivate.
  • Delete the entire interview-coach project folder. This removes all code, templates, static files, and the virtual environment.
  • Revoke your Groq API key by going to console.groq.com, navigating to API Keys, and deleting the key you created.
  • Optionally, delete your Groq account from the account settings page if you no longer need it.

Mission accomplished

That's a Wrap!

Nice work! You just built a full-stack AI Mock Interview Coach from scratch. You have a working web app that generates tailored interview questions, evaluates your answers with structured scoring, and even transcribes audio responses.

Here's what you learned:

  • Built a FastAPI backend with multiple API endpoints, Pydantic models, file uploads, and Jinja2-templated frontend pages all served from a single server.
  • Integrated Groq's Llama 3.3 70B for AI-powered question generation and answer evaluation, using prompt engineering to get consistent, structured JSON output from an LLM.
  • Added speech-to-text support using Groq's Whisper Large V3 to transcribe audio file uploads and feed them into the evaluation pipeline.
  • Implemented difficulty progression that dynamically adjusts question complexity based on your running average score, controlled entirely through prompt engineering.

Ready to quiz yourself?