Offline GATE Mock Test App

Build a local GATE CS mock test app with official scoring and AI coaching.

Introduction

30 Second Summary

Past papers can show where you lose marks, but only after you reproduce the pressure of the real exam. A folder of PDFs cannot track time, apply penalties, or reveal a pattern across attempts.

In this project, you will build an offline GATE CS mock-test app that opens official question papers, scores responses from official answer keys, and tracks weak syllabus areas. Local Gemma will classify questions and generate post-test study guidance through Ollama.

What You'll Build

You will open a 180-minute mock, answer MCQ, MSQ, and NAT questions from the official PDFs, then see your score and weakest syllabus sections without sending the papers online.

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

  • An offline paper vault that pairs each question paper with its official answer key and rejects mismatched sessions.
  • A GATE-pattern test runner with 65 questions, a 180-minute timer, review markers, and controls for MCQ, MSQ, and NAT responses.
  • A deterministic score report that applies official negative marking and shows performance by question type, year, and syllabus section.
  • Secret Mission: Build a 15-question adaptive drill from incorrect and unattempted questions in your weakest topics.

Are there any prerequisites?

You'll need Windows, Python, Antigravity IDE, Ollama, the local Gemma model, and your official GATE CS PDFs. The hands-on path uses the supplied 2026 CS-1 and CS-2 files before you add papers from 2021 through 2025.

Before We Start

Before We Start...

Before you build the test runner, lock in what you want it to improve. Your target score and first source paper give every later report a clear purpose.

Verify the Workbench

Set Up the Offline Workbench

The mock engine depends on three local pieces: a supported Python interpreter, a PDF renderer, and a running local model. Verifying them first prevents an import problem from being mistaken for a question-paper problem.

You will also organize the supplied CS-1 and CS-2 files into matching paper and answer-key pairs. That pairing protects every later score from using the wrong session key.

In this step, get ready to:
  • Match each 2026 question paper with its session answer key.
  • Create an isolated Python workspace and install PyMuPDF.
  • Confirm that Ollama can run Gemma locally.
Pair the source PDFs

The paper title and the answer key's Section column must name the same session. The files you supplied contain two valid 2026 pairs.

  • Create a folder named offline-gate-mock-test on your Desktop.
  • Create a folder named papers inside offline-gate-mock-test.
  • Create a folder named keys beside papers.
  • Copy CS1_2026.pdf and CS2_2026.pdf into papers.
  • Copy CS1_2026_Keys.pdf and CS2_2026_Keys.pdf into keys.

Why validate the session twice?

The filenames make the valid pairs visible: CS1 matches CS-1, while CS2 matches CS-2. The importer will repeat this check from the answer-key table instead of trusting filenames alone.

Prepare the Python workspace
  • Press Windows to open the search bar.
  • Type Antigravity IDE and press Enter.
  • Select File from the top menu.
  • Select Open Folder.
  • Select the offline-gate-mock-test folder on your Desktop.
  • Select Terminal from the top menu.
  • Select New Terminal.
  • Check the installed Python version by running this command:
python --version

Choose the tab that matches the version shown in your terminal.

✔️ I see Python 3.11 or higher

Your output shows Python 3.11 or newer. Continue with the virtual environment below.

ⓧ I see an older version

Install a current Python release from the official Windows downloads page, then open a new Antigravity IDE terminal. The project needs Python 3.11 or newer for the supported PyMuPDF wheel path.

ⓧ Python is not found

Install Python from the official Windows downloads page. Return to Antigravity IDE and create a new terminal after the installer finishes.

  • Create an isolated environment inside the project by running:
python -m venv .venv
  • Activate the new environment by running:
.\.venv\Scripts\Activate.ps1
  • Install the PDF library in the active environment by running:
python -m pip install --upgrade pymupdf

The first installation can take a minute while pip downloads the Windows wheel. The terminal remains active while the package is installed.

  • Verify the installation by running:
python -c "import pymupdf; print(pymupdf.__doc__)"

You should see text beginning with PyMuPDF. That output proves the active environment can import the PDF renderer.

Activation script blocked?

If PowerShell blocks Activate.ps1, use the Python interpreter inside .venv\Scripts directly for the remaining commands. Help me use the virtual environment when activation is blocked.

Verify the local model

Ollama keeps the question text and your attempt history on your computer. The deterministic scorer works without Gemma, while the study coach uses the local model after submission.

  • Check the installed Ollama version by running:
ollama --version

Choose the tab that matches the terminal result.

✔️ I see Ollama 0.6 or higher

Your Ollama installation is available. Continue to the model check.

ⓧ I see an older version

Update Ollama through the official Windows installer, then open a new terminal. Gemma 3 requires Ollama 0.6 or newer.

ⓧ Command not found

Install Ollama from its official Windows documentation, then create a new terminal. The installer adds the command to new terminal sessions.

  • Download and open the local Gemma chat by running:
ollama run gemma3:1b

The first run downloads about 806 MB, so it can take a few minutes. The progress counter confirms that the model is still downloading.

  • Enter Classify this topic: TCP congestion control at the chat prompt.
  • Wait for Gemma to return a response.
  • Leave the chat by entering this command:
/bye

Your workbench is ready. Python can render the official PDFs, and Gemma can respond without a cloud API key.

Local model not responding?

Confirm that the Ollama tray app is running before retrying the model command. Check that the model name is exactly gemma3:1b. Help me start the local Gemma model.

The offline tools and source pairs are ready. Next, you will turn those PDFs into a validated paper index that the app can navigate.

Build the Haunted Shell

Build the Offline Paper Vault

Your source pairs and local tools are ready. The next goal is to turn two PDF pairs into a paper index the app can trust.

The question pages contain equations, diagrams, and formatting that plain text extraction can damage. You will preserve each original page for display while extracting only the structured answer-key table and question-to-page map.

In this step, get ready to:
  • Extract question type, section, official answer, and marks from each key.
  • Map all 65 question numbers to their source PDF pages.
  • Reject mismatched sessions and save a reusable local index.
Extract the answer-key tables

PyMuPDF detects the ruled table on each answer-key page and returns its cells as rows. This keeps the official key separate from any AI-generated explanation.

  • Create paper_store.py inside the offline-gate-mock-test folder.
  • Build the answer-row extractor by adding this code to paper_store.py:
from pathlib import Path
import json
import re

import pymupdf

QUESTION_PATTERN = re.compile(r"Q\.\s*(\d+)")


def extract_answer_rows(key_path):
    # Preserve the official table as the scoring source.
    answers = []
    with pymupdf.open(key_path) as document:
        for page in document:
            tables = page.find_tables()
            for table in tables.tables:
                for row in table.extract():
                    cells = [
                        (cell or "").replace("\n", " ").strip()
                        for cell in row
                    ]
                    if len(cells) != 6 or not cells[0].isdigit():
                        continue
                    answers.append({
                        "number": int(cells[0]),
                        "type": cells[2],
                        "section": cells[3],
                        "key": cells[4],
                        "marks": int(cells[5]),
                    })
    return answers

What does this code do?

  • The table detector keeps MCQ, MSQ, and NAT answers in the same six-column structure used by the PDF.
  • The row filter skips repeated headings and footer text because their first cell is not a question number.
  • The saved fields contain everything the deterministic scorer needs later.
  • Save paper_store.py.
  • Check that Python can load the new module by running:
python -c "import paper_store; print('Answer-key parser loaded')"

You should see Answer-key parser loaded in the terminal. That confirms the imports and function definition are valid.

Parser module not loading?

Confirm that the active terminal still shows the virtual-environment prefix. Check that the package is imported as pymupdf, not the unrelated fitz package. Help me load the answer-key parser.

Map questions and validate sessions

The app will render the original PDF page instead of reconstructing mathematical content. A compact map connects each question number to the last page where its Q. heading appears.

  • Add the page mapper below extract_answer_rows() in paper_store.py by pasting:
def map_question_pages(paper_path):
    # Keep diagrams intact by pointing to original PDF pages.
    question_pages = {}
    with pymupdf.open(paper_path) as document:
        for page in document:
            text = page.get_text(sort=True)
            for match in QUESTION_PATTERN.finditer(text):
                number = int(match.group(1))
                if 1 <= number <= 65:
                    question_pages[number] = page.number
    return question_pages


def session_from_answers(answers):
    # GA is shared, so use the paper-specific subject label.
    sessions = {
        row["section"]
        for row in answers
        if row["section"].startswith("CS-")
    }
    if len(sessions) != 1:
        raise ValueError("Expected one CS session in the answer key")
    return sessions.pop()

Why preserve page numbers?

  • The regular expression finds numbered Q. headings while ignoring values outside the official 1-to-65 range.
  • Later occurrences replace section-range headings, so each number settles on its actual question page.
  • The session check ignores GA rows and requires one subject label such as CS-1.
  • Add the pair validator below session_from_answers() by pasting:
def build_paper_record(paper_path, key_path):
    # Validate the pair before any score can use it.
    answers = extract_answer_rows(key_path)
    question_pages = map_question_pages(paper_path)
    session = session_from_answers(answers)
    expected = session.replace("-", "")

    if expected not in paper_path.stem.upper():
        raise ValueError(
            f"{paper_path.name} does not match {session}"
        )
    if len(answers) != 65 or len(question_pages) != 65:
        raise ValueError(
            f"Expected 65 answers and pages for {paper_path.name}"
        )

    return {
        "paper": paper_path.as_posix(),
        "key": key_path.as_posix(),
        "session": session,
        "answers": answers,
        "question_pages": question_pages,
    }

What does validation protect?

  • The session label must appear in the paper filename after its hyphen is removed.
  • Both the answer list and page map must contain exactly 65 entries.
  • A failed check stops the import before the pair reaches the scoring data.
  • Save paper_store.py.
  • Confirm that the three functions load by running:
python -c "from paper_store import build_paper_record; print('Pair validator loaded')"

You should see Pair validator loaded. The importer is now ready to inspect real files.

Scan the paper vault
  • Add the folder scanner at the bottom of paper_store.py by pasting:
def scan_papers():
    # Match every *_Keys.pdf file with the same paper stem.
    records = []
    for key_path in sorted(Path("keys").glob("*_Keys.pdf")):
        stem = key_path.stem.removesuffix("_Keys")
        paper_path = Path("papers") / f"{stem}.pdf"
        if not paper_path.exists():
            raise FileNotFoundError(f"Missing {paper_path}")
        record = build_paper_record(paper_path, key_path)
        records.append(record)
        print(
            f"{stem}: {len(record['answers'])} answers, "
            f"{len(record['question_pages'])} question pages"
        )

    Path("data").mkdir(exist_ok=True)
    Path("data/papers.json").write_text(
        json.dumps(records, indent=2), encoding="utf-8"
    )
    print(f"Saved {len(records)} papers to data/papers.json")


if __name__ == "__main__":
    scan_papers()

How does the scan work?

  • The filename rule pairs CS1_2026_Keys.pdf with CS1_2026.pdf.
  • Each validated record carries the source paths, session, answer rows, and page map.
  • The final JSON file becomes the app's offline paper catalogue.
  • Save paper_store.py.

Before you run the scan, do you think both session pairs will pass the 65-question checks?

  • Build the local paper index by running:
python paper_store.py

You should see two lines reporting 65 answers and 65 question pages. The final line should confirm that two papers were saved to data/papers.json.

Fewer than 65 entries?

Table detection can fail if an answer-key file is an image-only scan or uses a layout unlike the supplied 2026 keys. Confirm that you paired CS1 with CS1 and CS2 with CS2 before changing the parser. Help me inspect an incomplete paper import.

✔️ I imported both sessions

Your paper vault now contains two validated 2026 sessions. Save paper_store.py and keep data/papers.json for the next step.

ⓧ I'd like to double check the full code

Compare your file with this complete reference.

from pathlib import Path
import json
import re

import pymupdf

QUESTION_PATTERN = re.compile(r"Q\.\s*(\d+)")


def extract_answer_rows(key_path):
    # Preserve the official table as the scoring source.
    answers = []
    with pymupdf.open(key_path) as document:
        for page in document:
            tables = page.find_tables()
            for table in tables.tables:
                for row in table.extract():
                    cells = [
                        (cell or "").replace("\n", " ").strip()
                        for cell in row
                    ]
                    if len(cells) != 6 or not cells[0].isdigit():
                        continue
                    answers.append({
                        "number": int(cells[0]),
                        "type": cells[2],
                        "section": cells[3],
                        "key": cells[4],
                        "marks": int(cells[5]),
                    })
    return answers


def map_question_pages(paper_path):
    # Keep diagrams intact by pointing to original PDF pages.
    question_pages = {}
    with pymupdf.open(paper_path) as document:
        for page in document:
            text = page.get_text(sort=True)
            for match in QUESTION_PATTERN.finditer(text):
                number = int(match.group(1))
                if 1 <= number <= 65:
                    question_pages[number] = page.number
    return question_pages


def session_from_answers(answers):
    # GA is shared, so use the paper-specific subject label.
    sessions = {
        row["section"]
        for row in answers
        if row["section"].startswith("CS-")
    }
    if len(sessions) != 1:
        raise ValueError("Expected one CS session in the answer key")
    return sessions.pop()


def build_paper_record(paper_path, key_path):
    # Validate the pair before any score can use it.
    answers = extract_answer_rows(key_path)
    question_pages = map_question_pages(paper_path)
    session = session_from_answers(answers)
    expected = session.replace("-", "")

    if expected not in paper_path.stem.upper():
        raise ValueError(
            f"{paper_path.name} does not match {session}"
        )
    if len(answers) != 65 or len(question_pages) != 65:
        raise ValueError(
            f"Expected 65 answers and pages for {paper_path.name}"
        )

    return {
        "paper": paper_path.as_posix(),
        "key": key_path.as_posix(),
        "session": session,
        "answers": answers,
        "question_pages": question_pages,
    }


def scan_papers():
    # Match every *_Keys.pdf file with the same paper stem.
    records = []
    for key_path in sorted(Path("keys").glob("*_Keys.pdf")):
        stem = key_path.stem.removesuffix("_Keys")
        paper_path = Path("papers") / f"{stem}.pdf"
        if not paper_path.exists():
            raise FileNotFoundError(f"Missing {paper_path}")
        record = build_paper_record(paper_path, key_path)
        records.append(record)
        print(
            f"{stem}: {len(record['answers'])} answers, "
            f"{len(record['question_pages'])} question pages"
        )

    Path("data").mkdir(exist_ok=True)
    Path("data/papers.json").write_text(
        json.dumps(records, indent=2), encoding="utf-8"
    )
    print(f"Saved {len(records)} papers to data/papers.json")


if __name__ == "__main__":
    scan_papers()

That is the first working slice: two real GATE sessions are now validated and searchable without altering their pages. Next, you will turn one indexed paper into a timed CBT window.

Give the Relic a Voice

Run the Offline GATE Mock Test

Your paper vault can now prove that every imported session contains 65 answers and 65 question pages. The next goal is to make those records feel like a real computer-based test.

The window will preserve each official PDF page while adding a countdown timer, question palette, response controls, and review state. This creates exam pressure without altering the source material.

In this step, get ready to:
  • Render official question pages inside a scrollable desktop window.
  • Collect MCQ, MSQ, and NAT responses with the correct controls.
  • Navigate 65 questions with a timer and review palette.
Render the official paper

The source PDF remains the visual authority for equations and diagrams. The app renders its pages into a local cache, then places each image on a scrollable Tkinter canvas.

  • Create exam_app.py beside paper_store.py.
  • Load the first indexed paper by adding this code to exam_app.py:
from pathlib import Path
import json
import tkinter as tk

import pymupdf

# Load the validated catalogue created in Step 2.
papers = json.loads(
    Path("data/papers.json").read_text(encoding="utf-8")
)
paper = papers[0]
answers = {
    int(row["number"]): row for row in paper["answers"]
}
document = pymupdf.open(paper["paper"])
responses = {}
marked = set()
current = 1
remaining = 180 * 60
response_vars = []
palette_buttons = []

Path("cache").mkdir(exist_ok=True)

What does the app load?

  • The catalogue supplies the source path, answer metadata, and page map.
  • The response and review collections keep the current attempt in memory.
  • The countdown starts at 180 minutes to match the official duration.
  • Add the page renderer below the state variables by pasting:
def render_question():
    # Render the original page so diagrams remain intact.
    page_number = int(paper["question_pages"][str(current)])
    cache_name = f"{paper['session']}-{page_number}.png"
    cache_path = Path("cache") / cache_name

    if not cache_path.exists():
        pixmap = document[page_number].get_pixmap(
            dpi=120, alpha=False
        )
        pixmap.save(cache_path)

    image = tk.PhotoImage(file=cache_path)
    page_canvas.delete("all")
    page_canvas.create_image(0, 0, anchor="nw", image=image)
    page_canvas.image = image
    page_canvas.configure(
        scrollregion=(0, 0, image.width(), image.height())
    )
    page_canvas.yview_moveto(0)

How does page rendering work?

  • The page map converts the current question number into a zero-based PDF page.
  • The cache avoids rendering the same page again when you revisit a question.
  • The retained page_canvas.image reference prevents Tkinter from discarding the displayed image.
Collect each response type

Each question type needs a different input. MCQs accept one option, MSQs accept several options, and NAT questions accept a typed number.

  • Add the dynamic response panel below render_question() by pasting:
def show_response_controls():
    # Rebuild the controls for the current question type.
    global response_vars
    for child in response_frame.winfo_children():
        child.destroy()

    metadata = answers[current]
    kind = metadata["type"]
    saved = responses.get(current, "")
    question_label.config(text=f"Question {current} of 65")
    type_label.config(text=f"{kind} · {metadata['marks']} mark(s)")
    response_vars = []

    if kind == "MCQ":
        variable = tk.StringVar(value=saved)
        response_vars.append(variable)
        for option in "ABCD":
            tk.Radiobutton(
                response_frame, text=option, variable=variable,
                value=option, bg="#111827", fg="white",
                selectcolor="#374151",
            ).pack(anchor="w")
    elif kind == "MSQ":
        selected = set(saved.split(";"))
        for option in "ABCD":
            variable = tk.BooleanVar(value=option in selected)
            response_vars.append(variable)
            tk.Checkbutton(
                response_frame, text=option, variable=variable,
                bg="#111827", fg="white", selectcolor="#374151",
            ).pack(anchor="w")
    else:
        variable = tk.StringVar(value=saved)
        response_vars.append(variable)
        tk.Entry(response_frame, textvariable=variable).pack(fill="x")

Why use three control shapes?

  • A radio-button variable stores one MCQ option.
  • Four Boolean variables preserve every selected MSQ option.
  • A text entry keeps NAT values flexible enough for decimals and negative numbers.
  • Add the response saver and navigation functions below show_response_controls() by pasting:
def save_response():
    # Store one normalized value for every question type.
    kind = answers[current]["type"]
    if kind == "MSQ":
        value = ";".join(
            option for option, variable in zip("ABCD", response_vars)
            if variable.get()
        )
    else:
        value = response_vars[0].get().strip()

    if value:
        responses[current] = value
    else:
        responses.pop(current, None)
    update_palette()


def go_to(number):
    global current
    save_response()
    current = max(1, min(65, number))
    refresh()


def save_and_next():
    save_response()
    if current < 65:
        go_to(current + 1)


def toggle_mark():
    if current in marked:
        marked.remove(current)
    else:
        marked.add(current)
    update_palette()

How is attempt state preserved?

  • MSQ choices are stored as a semicolon-separated string such as A;C;D.
  • Empty controls remove any previous response instead of storing a blank answer.
  • Every navigation action saves the current question before changing the screen.
Assemble the CBT window
  • Add the palette, session switcher, and timer functions below toggle_mark() by pasting:
def update_palette():
    # Color each question by its current attempt state.
    for number, button in enumerate(palette_buttons, start=1):
        color = "#334155"
        if number in responses:
            color = "#15803d"
        if number in marked:
            color = "#b45309"
        if number == current:
            color = "#7c3aed"
        button.configure(bg=color, activebackground=color)


def refresh():
    render_question()
    show_response_controls()
    update_palette()


def load_session(session):
    global paper, answers, document, current, remaining
    document.close()
    paper = next(item for item in papers if item["session"] == session)
    answers = {int(row["number"]): row for row in paper["answers"]}
    document = pymupdf.open(paper["paper"])
    responses.clear()
    marked.clear()
    current = 1
    remaining = 180 * 60
    refresh()


def tick():
    global remaining
    minutes, seconds = divmod(max(remaining, 0), 60)
    timer_label.config(text=f"{minutes:03d}:{seconds:02d}")
    if remaining > 0:
        remaining -= 1
        root.after(1000, tick)

What keeps the screen current?

  • Green marks answered questions, amber marks review questions, and purple marks the current question.
  • Changing sessions clears the attempt and opens the matching source PDF.
  • The timer schedules one update per second without blocking the interface.
  • Add the root window and header below the timer function by pasting:
root = tk.Tk()
root.title("Offline GATE Mock Test App")
root.geometry("1280x820")
root.minsize(1000, 700)
root.configure(bg="#0f172a")

topbar = tk.Frame(root, bg="#0f172a")
topbar.pack(fill="x", padx=16, pady=12)

tk.Label(
    topbar, text="GATE CS OFFLINE MOCK", bg="#0f172a",
    fg="white", font=("Segoe UI", 18, "bold"),
).pack(side="left")

session_var = tk.StringVar(value=paper["session"])
tk.OptionMenu(
    topbar, session_var, *[item["session"] for item in papers],
    command=load_session,
).pack(side="left", padx=18)

timer_label = tk.Label(
    topbar, text="180:00", bg="#0f172a", fg="#fbbf24",
    font=("Consolas", 18, "bold"),
)
timer_label.pack(side="right")

body = tk.Frame(root, bg="#0f172a")
body.pack(fill="both", expand=True, padx=16, pady=(0, 16))

How is the exam framed?

  • The header keeps the session selector and official-duration timer visible.
  • The resizable body holds the source page and response sidebar.
  • Add the PDF viewer below the body frame by pasting:
viewer = tk.Frame(body, bg="#e5e7eb")
viewer.pack(side="left", fill="both", expand=True)

page_canvas = tk.Canvas(viewer, bg="#e5e7eb", highlightthickness=0)
page_scroll = tk.Scrollbar(
    viewer, orient="vertical", command=page_canvas.yview
)
page_canvas.configure(yscrollcommand=page_scroll.set)
page_scroll.pack(side="right", fill="y")
page_canvas.pack(side="left", fill="both", expand=True)

sidebar = tk.Frame(body, width=310, bg="#111827")
sidebar.pack(side="right", fill="y", padx=(14, 0))
sidebar.pack_propagate(False)

question_label = tk.Label(
    sidebar, bg="#111827", fg="white",
    font=("Segoe UI", 14, "bold"),
)
question_label.pack(anchor="w", padx=14, pady=(14, 2))

type_label = tk.Label(sidebar, bg="#111827", fg="#93c5fd")
type_label.pack(anchor="w", padx=14, pady=(0, 10))

Why split the screen?

  • The canvas scrolls through the unchanged official page image.
  • The fixed-width sidebar keeps question metadata and response controls stable.
  • Finish the sidebar and start the app by adding:
palette = tk.Frame(sidebar, bg="#111827")
palette.pack(fill="x", padx=10)
for number in range(1, 66):
    button = tk.Button(
        palette, text=str(number), width=3, fg="white",
        relief="flat", command=lambda n=number: go_to(n),
    )
    button.grid(row=(number - 1) // 7, column=(number - 1) % 7, padx=2, pady=2)
    palette_buttons.append(button)

response_frame = tk.LabelFrame(
    sidebar, text="Your response", bg="#111827", fg="white",
    padx=10, pady=10,
)
response_frame.pack(fill="x", padx=14, pady=14)

tk.Button(
    sidebar, text="Save and Next", command=save_and_next,
    bg="#2563eb", fg="white",
).pack(fill="x", padx=14, pady=4)
tk.Button(
    sidebar, text="Mark for Review", command=toggle_mark,
    bg="#b45309", fg="white",
).pack(fill="x", padx=14, pady=4)
tk.Button(
    sidebar, text="Previous", command=lambda: go_to(current - 1),
).pack(fill="x", padx=14, pady=4)

def close_app():
    # Close the PDF cleanly before destroying the window.
    document.close()
    root.destroy()

root.protocol("WM_DELETE_WINDOW", close_app)
refresh()
tick()
root.mainloop()

What completes the interaction?

  • The 65-button palette provides direct question access.
  • The action buttons save, review, or revisit the current response.
  • The close handler releases the open PDF before the desktop window exits.
  • Save exam_app.py.

Before you launch the test, which control do you expect to change when you move from an MCQ to an MSQ?

  • Start the offline CBT by running:
python exam_app.py

You should see the official CS-1 page on the left and a 65-question palette on the right. Moving between Q.1, Q.19, and Q.58 should switch the response area from radio buttons to checkboxes to a numeric field.

CBT window incomplete?

If the PDF area stays blank, confirm that data/papers.json still points to the files inside papers. Delete the cache folder if a partial PNG was created during an interrupted run. Help me repair the CBT window.

You now have a real exam loop: the official pages render, the timer moves, and all three response types retain their values. Next, you will submit an attempt and calculate its score from the official key.

Keep the Window Alive

Score the Offline Mock Test

Your CBT now preserves answers, review state, and the official timer across all 65 questions. The missing piece is the moment that turns those responses into evidence about your preparation.

You will calculate the score from the official answer key before Gemma sees any result. The local model can then explain patterns, but it cannot change marks or decide whether an answer is correct.

In this step, get ready to:
  • Apply official MCQ penalties and zero-penalty MSQ and NAT rules.
  • Save and display a deterministic attempt report.
  • Generate optional local study guidance from incorrect questions.
Build the deterministic scorer

MCQ questions lose one-third of their marks after an incorrect response. Incorrect MSQ and NAT responses receive zero, while an unattempted question always receives zero.

  • Create scoring.py inside offline-gate-mock-test.
  • Add choice normalization and NAT range matching by pasting:
import re


def normalize_choices(value):
    # Store comparable MCQ and MSQ option strings.
    parts = [part.strip().upper() for part in value.split(";")]
    return ";".join(sorted(part for part in parts if part))


def nat_matches(response, official_key):
    # Accept an exact number or an inclusive official range.
    try:
        value = float(response)
    except ValueError:
        return False

    match = re.fullmatch(
        r"\s*(-?\d+(?:\.\d+)?)\s+to\s+"
        r"(-?\d+(?:\.\d+)?)\s*",
        official_key,
    )
    if match:
        lower, upper = map(float, match.groups())
        return lower <= value <= upper

    try:
        return abs(value - float(official_key)) < 1e-9
    except ValueError:
        return False

How are responses compared?

  • Choice normalization makes C;A compare correctly with an official A;C MSQ key.
  • The range matcher treats both endpoints as correct for NAT keys such as 4.24 to 4.26.
  • Non-numeric NAT input fails safely instead of stopping the report.
  • Add the per-question scorer below nat_matches() by pasting:
def score_question(item, response):
    # Determine correctness before assigning marks.
    if not response:
        return "unattempted", 0.0

    kind = item["type"]
    official = item["key"]
    if kind == "NAT":
        correct = nat_matches(response, official)
    else:
        correct = normalize_choices(response) == normalize_choices(official)

    if correct:
        return "correct", float(item["marks"])
    if kind == "MCQ":
        return "incorrect", -(float(item["marks"]) / 3)
    return "incorrect", 0.0


def score_attempt(answers, responses):
    # Aggregate all 65 official-key decisions.
    questions = []
    for item in answers:
        number = int(item["number"])
        response = responses.get(number, "")
        verdict, points = score_question(item, response)
        questions.append({
            **item, "response": response,
            "verdict": verdict, "points": round(points, 2),
        })

    return {
        "score": round(sum(row["points"] for row in questions), 2),
        "correct": sum(row["verdict"] == "correct" for row in questions),
        "incorrect": sum(row["verdict"] == "incorrect" for row in questions),
        "unattempted": sum(row["verdict"] == "unattempted" for row in questions),
        "questions": questions,
    }

Why keep question-level results?

  • Correct responses earn the mark value printed in the key.
  • Only an incorrect MCQ response enters the negative-marking branch.
  • The final report keeps every question verdict so analytics remain auditable.
  • Save scoring.py.
  • Verify the penalty calculation by running:
python -c "from scoring import score_question; print(score_question({'type':'MCQ','key':'B','marks':2}, 'A'))"

You should see ('incorrect', -0.6666666666666666) or an equivalent two-thirds penalty. That proves a wrong two-mark MCQ loses two-thirds of a mark.

Unexpected penalty result?

Check that marks remains numeric and that the official key is passed as a string. Help me correct the scoring test.

Submit and display an attempt
  • Add this import below the existing imports in exam_app.py:
from scoring import score_attempt
  • Add the report window below tick() by pasting:
def show_report(report):
    # Display the deterministic result before any AI guidance.
    window = tk.Toplevel(root)
    window.title("Attempt Report")
    window.geometry("640x620")

    summary = (
        f"Score: {report['score']} / 100\n"
        f"Correct: {report['correct']}   "
        f"Incorrect: {report['incorrect']}   "
        f"Unattempted: {report['unattempted']}"
    )
    tk.Label(
        window, text=summary, font=("Segoe UI", 14, "bold"),
        justify="left",
    ).pack(anchor="w", padx=18, pady=18)

    details = tk.Text(window, wrap="word")
    details.pack(fill="both", expand=True, padx=18, pady=(0, 18))
    for row in report["questions"]:
        if row["verdict"] != "correct":
            details.insert(
                "end", f"Q{row['number']} · {row['type']} · "
                f"{row['verdict']} · {row['points']} marks\n"
            )
    details.configure(state="disabled")

What appears in the report?

  • The headline reports the official-key score and attempt counts.
  • The detail area lists every incorrect or unattempted question without hiding penalties.
  • Add the submit function below show_report() by pasting:
def submit_attempt():
    # Freeze the visible response into an auditable JSON report.
    save_response()
    report = score_attempt(list(answers.values()), responses)
    report["session"] = paper["session"]
    Path("data/last_attempt.json").write_text(
        json.dumps(report, indent=2), encoding="utf-8"
    )
    show_report(report)
  • Find the existing Mark for Review button in exam_app.py.
  • Add the submit button directly below it by pasting:
tk.Button(
    sidebar, text="Submit Attempt", command=submit_attempt,
    bg="#dc2626", fg="white",
).pack(fill="x", padx=14, pady=12)
  • Save exam_app.py.
  • Start the scored CBT by running:
python exam_app.py
  • Answer Q.1 with any option.
  • Leave at least one question blank.
  • Select Submit Attempt.

You should see an attempt report with a score, counts, and question-level deductions. The same result is saved in data/last_attempt.json.

Generate the local study coach

The coach receives only post-submission evidence. It uses the attached syllabus as a fixed topic list and treats the official key as settled fact.

  • Create coach.py beside scoring.py.
  • Add the local prompt builder by pasting:
import json
import urllib.request

import pymupdf

OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "gemma3:1b"
SYLLABUS = (
    "Engineering Mathematics; Digital Logic; Computer Organization "
    "and Architecture; Programming and Data Structures; Algorithms; "
    "Theory of Computation; Compiler Design; Operating System; "
    "Databases; Computer Networks"
)


def build_prompt(evidence):
    # Keep AI in a coaching role after deterministic scoring.
    return (
        "You are a GATE CS study coach. The official key and score are final. "
        "Classify each sample under exactly one supplied syllabus section. "
        "Explain the likely misconception in one sentence, then finish with "
        "a three-item revision plan. Label the output AI-generated guidance.\n\n"
        f"SYLLABUS SECTIONS: {SYLLABUS}\n\n"
        f"POST-TEST EVIDENCE:\n{evidence}"
    )
  • Add the evidence collector and Ollama request below build_prompt() by pasting:
def generate_study_plan(report, paper):
    # Limit context to five missed questions for a fast local result.
    missed = [
        row for row in report["questions"]
        if row["verdict"] != "correct"
    ][:5]
    samples = []
    with pymupdf.open(paper["paper"]) as source:
        for row in missed:
            page_number = int(
                paper["question_pages"][str(row["number"])]
            )
            page_text = source[page_number].get_text(sort=True)[:1200]
            samples.append(
                f"Q{row['number']} | type={row['type']} | "
                f"response={row['response'] or 'blank'} | "
                f"official={row['key']}\n{page_text}"
            )

    payload = json.dumps({
        "model": MODEL,
        "prompt": build_prompt("\n\n".join(samples)),
        "stream": False,
    }).encode("utf-8")
    request = urllib.request.Request(
        OLLAMA_URL, data=payload,
        headers={"Content-Type": "application/json"}, method="POST",
    )
    with urllib.request.urlopen(request, timeout=180) as response:
        data = json.loads(response.read().decode("utf-8"))
    return data["response"].strip()

What can Gemma influence?

  • The model sees at most five missed questions, which keeps the local request manageable.
  • Each sample includes the learner response and official key only after submission.
  • The prompt asks for syllabus classification and revision guidance instead of rescoring.
  • Add import threading to the import block in exam_app.py.
  • Add this coach import below the scoring import:
from coach import generate_study_plan
  • Add the coach controls at the end of show_report() by pasting:
    coach_output = tk.Text(window, height=12, wrap="word")
    coach_output.pack(fill="x", padx=18, pady=(0, 12))

    def finish_coach(text):
        coach_output.delete("1.0", "end")
        coach_output.insert("1.0", text)
        coach_button.configure(state="normal")

    def coach_worker():
        try:
            plan = generate_study_plan(report, paper)
        except Exception as error:
            plan = f"Study coach unavailable: {error}"
        root.after(0, finish_coach, plan)

    def start_coach():
        coach_button.configure(state="disabled")
        coach_output.delete("1.0", "end")
        coach_output.insert("1.0", "Gemma is reviewing missed questions...")
        threading.Thread(target=coach_worker, daemon=True).start()

    coach_button = tk.Button(
        window, text="Generate Local Study Coach",
        command=start_coach,
    )
    coach_button.pack(pady=(0, 14))

The worker thread protects the report window while local inference runs. The result returns through root.after() so Tkinter updates its widgets on the main event loop.

  • Save coach.py.
  • Save exam_app.py.

Before the final test, do you expect the score to change after Gemma generates its guidance?

  • Run the finished app by entering:
python exam_app.py
  • Submit a short practice attempt.
  • Record the score shown at the top of the report.
  • Select Generate Local Study Coach.
  • Wait for the AI-generated guidance to replace the loading message.

You should see syllabus classifications and a three-item revision plan below the unchanged official score. That separation proves the app uses AI for coaching, not marking.

Study coach unavailable?

Confirm that Ollama is running and gemma3:1b remains installed. If the score report works but the coach fails, your deterministic exam data is still safe. Help me restore the local study coach.

The exam loop is complete: official PDFs supply the questions, official keys supply the marks, and local Gemma turns missed questions into a study plan. Next, you will package that design as a Hacktoberfest challenge entry.

Publish the Challenge Entry

Submit the Hacktoberfest 2026 Entry

Your app now turns local official papers into a timed attempt, an auditable score, and optional study guidance. Reviewers still need a safe way to inspect that design without receiving the question-paper PDFs or your attempt history.

You will package the source code for GitHub and document the trust boundary for DEV Community. The Hacktoberfest 2026 entry will present the product as an offline GATE mock test that turns past papers into active practice with local open-weight AI.

In this step, get ready to:
  • Exclude official PDFs, generated caches, and attempt data from Git.
  • Document the offline architecture and record a redacted demo.
  • Publish the repository and complete the Hacktoberfest 2026 submission.
Prepare a safe repository

The public repository should contain the parser, test runner, scorer, coach, and setup notes. The downloaded papers and keys remain local because they are source material, not project code.

  • Create .gitignore inside offline-gate-mock-test.
  • Exclude local-only files by pasting:
.venv/
__pycache__/
cache/
papers/
keys/
data/papers.json
data/last_attempt.json

What stays private?

  • The virtual environment and Python cache can be recreated.
  • The PDF folders remain on the learner's computer.
  • Generated indexes and attempt reports cannot leak into a commit.
  • Save .gitignore.
  • Create requirements.txt beside it.
  • Pin the verified PDF dependency by adding:
pymupdf==1.28.2
  • Create README.md inside offline-gate-mock-test.
  • Describe the product and its trust boundary by pasting:
# Offline GATE Mock Test App

An offline-first GATE CS mock-test app built from locally supplied official question papers and answer keys.

## Why it exists

Past papers become more useful when they reproduce the timer, response controls, review state, marking rules, and post-test reflection of a real CBT.

## Trust boundary

- Official answer-key tables determine every mark.
- Gemma never decides correctness or changes a score.
- Local Gemma guidance appears only after submission.
- Question papers, answer keys, caches, and attempts stay outside Git.

## Features

- CS-1 and CS-2 paper validation.
- Original-page rendering for equations and diagrams.
- MCQ, MSQ, and NAT response controls.
- A 180-minute timer and 65-question palette.
- Official negative marking for MCQs.
- Local syllabus classification and revision guidance.

What should reviewers understand first?

  • The overview describes the exam experience instead of listing implementation files.
  • The trust boundary makes deterministic scoring easy for reviewers to verify.
  • The feature list identifies where local Gemma adds value without overstating its authority.
  • Append the setup and architecture sections to README.md by pasting:
## Run locally

1. Create and activate a Python virtual environment.
2. Install the packages from `requirements.txt`.
3. Run `ollama run gemma3:1b` once to download the local model.
4. Put matching paper PDFs in `papers/` and key PDFs in `keys/`.
5. Run `python paper_store.py` to build the local index.
6. Run `python exam_app.py` to open the CBT.

## Architecture

1. `paper_store.py` validates PDF pairs and builds page maps.
2. `exam_app.py` renders source pages and records responses.
3. `scoring.py` applies the official answer key and marking rules.
4. `coach.py` sends post-test evidence to local Gemma through Ollama.

## Hacktoberfest 2026 entry

Submitted to the Hacktoberfest Open-Source AI Challenge Week 4.

The project turns past GATE papers into active offline practice. Local Gemma keeps post-test guidance private and available without a cloud AI service.

Partner category: Best Use of Gemma.
  • Save README.md.
  • Confirm the workspace contains paper_store.py, exam_app.py, scoring.py, coach.py, requirements.txt, .gitignore, and README.md.

Private files still visible?

Run git status only after initializing Git in the next substep. The output must not list anything inside papers, keys, cache, or data. Help me confirm that private files are ignored.

Record and publish the code

The public demo should prove the timer, response controls, scoring boundary, and local coach. Blur or cover the question-page text so the recording demonstrates your software without republishing the source paper.

  • Start the finished app by running:
python exam_app.py
  • Record the session selector and countdown timer.
  • Record one MCQ, one MSQ, and one NAT response.
  • Cover or blur the official question-page text in the capture.
  • Record the deterministic score report.
  • Record the local study coach below the unchanged score.
  • Publish the finished walkthrough with public visibility.
  • Record the walkthrough address as your public demo URL.
  • Create an empty public GitHub repository named offline-gate-mock-test.
  • Record its address as your public repository URL.
  • Initialize the local repository and inspect ignored files by running:
git init -b main
git status

You should see the source files and documentation as untracked. You should not see the PDF, cache, virtual-environment, index, or attempt files.

  • Create the first source-only commit by running:
git add .
git commit -m "Build offline GATE mock engine"
  • Connect the local repository to GitHub by running:
git remote add origin [[YOUR_REPO_URL="your public repository URL"]]
git remote -v

The remote check should print the same repository address for fetch and push. The first push can open a browser authentication flow, so complete that prompt before returning to the terminal.

  • Upload the committed main branch by running:
git push -u origin main
  • Refresh your public repository URL.
  • Confirm that the repository shows the five Python and documentation files.
  • Confirm that the repository contains no official PDFs or attempt reports.

The repository now proves the complete architecture without exposing the source papers. That separation is part of the product, not just cleanup.

Repository push failed?

Run git remote -v to confirm the destination. If authentication interrupts the push, finish the browser flow and retry the same command. Help me finish the safe GitHub push.

Submit the Hacktoberfest 2026 post

The Hacktoberfest post should explain the product's two trust zones. Official artifacts control questions and scores, while local open-weight AI turns evidence from past papers into a private revision plan.

  • Open the Hacktoberfest Open-Source AI Challenge Week 4 contest page on DEV Community.
  • Start a post from the official submission template.
  • Keep the #devchallenge and #hf26challenge tags supplied by the template.
  • Add your public repository URL.
  • Add your public demo URL.
  • Explain how the app turns past GATE papers into active offline practice.
  • Explain why local Gemma makes post-test guidance private and independent of a cloud AI service.
  • Identify Best Use of Gemma as the partner category.

What makes the AI meaningful?

The AI is central because it converts post-test evidence into a syllabus-based revision plan that a static scorer cannot write. The trust boundary is equally central because the same model never decides official correctness.

  • Publish the post before October 31, 2026 at 11:59 PM PDT.
  • Record the article address as your public DEV post URL.

Before the final check, what should a reviewer be able to verify without downloading a GATE paper?

  • Open your public DEV post URL in a private browser window.
  • Open the repository link from the post.
  • Confirm that the source code and trust-boundary explanation are public.
  • Open the demo link from the post.
  • Confirm that the redacted walkthrough shows scoring before AI guidance.

You have closed the challenge loop. Reviewers can inspect the offline architecture, watch the CBT flow, and see exactly where local Gemma begins and where its authority ends.

Secret mission

Build a 15-Question Recovery Drill

Turn the latest attempt into a 15-question recovery drill that prioritizes costly mistakes and opens each official source page for another try.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep the offline test runner ready, pause its local model, or remove the project resources. Everything runs locally or on free public pages, so there are no ongoing project costs.

Resources you used:

  • The local offline-gate-mock-test folder, including the private PDF vault, page cache, and attempt data.
  • The local Gemma model stored by Ollama as gemma3:1b.
  • The public GitHub repository named offline-gate-mock-test.

Keep everything running

No action needed. Choose this if you are still adding older papers, taking mocks, or improving the challenge demo.

  • Keep the offline-gate-mock-test folder on your Desktop.
  • Leave Ollama installed with gemma3:1b available.
  • Keep the GitHub repository public while its URL is used by your challenge entry.

Pause - I'll come back to this later

Shut down the running local model to free memory while preserving every paper, attempt, and downloaded model file.

  • Close the CBT window.
  • Quit the Ollama tray application from the Windows notification area.
  • Keep the offline-gate-mock-test folder and public repository unchanged.

Delete - I don't want to use this again

Remove the local project, downloaded model, and remote repository only when you no longer need the app or its challenge links.

Deleting these resources is permanent. Copy any private attempt history you want to keep before continuing.

  • Remove the Desktop project folder by running:
Remove-Item -Recurse -Force "$HOME\Desktop\offline-gate-mock-test"

The offline-gate-mock-test folder should no longer appear on your Desktop.

  • Delete the downloaded Gemma model by running:
ollama rm gemma3:1b

Ollama should confirm that gemma3:1b was removed.

  • Open the offline-gate-mock-test repository on GitHub.
  • Select Settings.
  • Select General.
  • Scroll to Danger Zone.
  • Select Delete this repository.
  • Enter the confirmation text requested by GitHub.
  • Complete the final repository deletion action.

The repository URL should stop loading after deletion. Any DEV post or demo that links to it remains online but now points to a missing repository.

Nice Work!

Nice Work!

Mission complete! You built an offline GATE CS test runner that preserves official PDF pages, applies official answer keys, and turns missed questions into local study guidance.

You've learned how to:

  • Build a validated local paper vault that pairs CS-1 and CS-2 question papers with their official keys while preserving equations and diagrams.
  • Run a GATE-pattern CBT with a 180-minute timer, 65-question palette, and dedicated MCQ, MSQ, and NAT controls.
  • Separate official scoring from local AI coaching so Gemma can guide revision without changing a single mark.
  • Complete the optional Secret Mission by turning the latest attempt into a ranked 15-question recovery drill.

Ready to quiz yourself?