Index Ai Academy
Phase 7 · Software engineering · Python projects

Project: a flashcard trainer

Cards from a file, review boxes, and progress saved between runs.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A program that forgets everything when it stops is a toy. The moment it saves its state and picks up where it left off, it becomes a tool, and two new problems arrive: the first run, when there's nothing saved yet, and the bad day, when the saved file is broken. Every app, from a to-do list to an agent's memory, has to handle both. This project also explains how the academy's own review cards decide what to show you.

What you'll be able to do

  • Load data from a CSV file into objects, and save progress as JSON
  • Handle a first run with no saved file, and a saved file that's damaged
  • Schedule reviews with boxes, so known cards come up less often
  • Combine classes, files, errors and input() into one program that remembers

What it covers

  1. The brief
  2. Boxes: how spaced review works
  3. Checkpoint 1: cards as objects
  4. Move a card between boxes
  5. Checkpoint 2: saving progress as JSON
  6. Checkpoint 3: the first run, and the bad day
  7. Worked example: one short session
  8. Go further

The brief

You'll build a flashcard trainer for the academy's own vocabulary: variable, loop, function and so on. Each card has a front (the term) and a back (what it means).

  • The cards start in a CSV file, one card per row.
  • In a session, the trainer shows each card that's due. You say whether you knew it.
  • Cards you know come up less and less often. Cards you miss come back every session.
  • Progress is saved to a JSON file, so the next session continues where this one stopped.
  • The first time, there's no progress file: start from the CSV. If the progress file is damaged, say so, and start from the CSV again rather than crashing.

That's four lessons in one program: classes, files, JSON and errors. You'll build it in pieces and check each one before joining them.

Boxes: how spaced review works

Reviewing everything every time wastes effort on what you already know. A simple, well-tested system uses numbered boxes, named after Sebastian Leitner, who described it in the 1970s:

  • Every card starts in box 1, which is reviewed every session.
  • Know a card, and it moves up a box. Box 2 is reviewed every second session, box 3 every fourth.
  • Miss a card, and it goes back to box 1, wherever it was.

So a card you keep getting right fades into the background, and a card you struggle with stays in front of you. The academy's review deck works on the same idea, with more careful timing: it uses days rather than sessions, and how hard you found a card, not just right or wrong.

In code, "every second session" is a remainder test, the same % you met with numbers:

EVERY = {1: 1, 2: 2, 3: 4}

for session in range(1, 9):
    due_boxes = [box for box, every in EVERY.items() if session % every == 0]
    print(session, due_boxes)
It runs right here in your browser, with nothing to install.

Box 1 is due every session, box 2 on even sessions, and box 3 on sessions 4 and 8.

Quick check
A card in box 2 is known, then missed in a later session. What does this print?
box = 2
box = min(box + 1, 3)   # known
print(box)
box = 1                 # missed
print(box)
  1. 2 then 1
  2. 3 then 1
  3. 3 then 2
  4. 3 then 3
Show the answer

3 then 1

Knowing it moves it up to box 3, but a miss always sends a card back to box 1, so it gets reviewed every session again.

Checkpoint 1: cards as objects

Each card is a small object with a box number. csv.DictReader reads the file one row at a time, as dictionaries keyed by the header row:

import csv

with open("cards.csv", "w") as f:
    f.write("front,back\nvariable,A name that holds a value\nloop,Code that repeats\n")

class Card:
    def __init__(self, front, back, box=1):
        self.front = front
        self.back = back
        self.box = box

    def __repr__(self):
        return f"Card({self.front!r}, box {self.box})"

def load_cards(path):
    with open(path) as f:
        return [Card(row["front"], row["back"]) for row in csv.DictReader(f)]

print(load_cards("cards.csv"))

The first lines write the CSV, so the example has a file to read. In the real program it would already exist, perhaps exported from a spreadsheet. __repr__ gives each card a readable printout, which makes the list on the last line easy to check.

Drill

Move a card between boxes

Write mark(card, known). A known card moves up one box, but never above box 3. A missed card goes back to box 1.

class Card:
    def __init__(self, front, back, box=1):
        self.front = front
        self.back = back
        self.box = box

def mark(card, known):
    pass
In the lesson, your code is checked as you write it.

Checkpoint 2: saving progress as JSON

The CSV holds the cards; progress also needs each card's box. JSON fits, because a list of dictionaries turns into JSON text and back without any extra code:

import json

progress = [
    {"front": "variable", "back": "A name that holds a value", "box": 2},
    {"front": "loop", "back": "Code that repeats", "box": 1},
]
with open("progress.json", "w") as f:
    json.dump(progress, f, indent=2)

with open("progress.json") as f:
    back_again = json.load(f)
print(back_again == progress, back_again[0]["box"])

json.dump writes the data to an open file, and json.load reads it back. indent=2 spreads it over several lines, so a person can open the file and read it. The comparison prints True: what you save is exactly what you get back.

Try it now

A saved progress file, as the program reads it back. Click a value to see the Python that reaches it, such as progress[2]["box"].

Checkpoint 3: the first run, and the bad day

Loading progress can go wrong in two ways, and they deserve different answers:

  • There's no file yet. open raises FileNotFoundError. That isn't a problem at all; it's the first run. Start from the CSV, quietly.
  • The file is damaged: half-written, or edited by hand. json.load raises json.JSONDecodeError. Say so, so the person knows their progress was lost, then start from the CSV.
import json

def load_progress(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError:
        return None
    except json.JSONDecodeError:
        print(f"Couldn't read {path}, so starting again.")
        return None

with open("broken.json", "w") as f:
    f.write('[{"front": "loop", "box": ')

print(load_progress("never-saved.json"))
print(load_progress("broken.json"))

Both cases return None, meaning "no usable progress", and the caller then builds a fresh deck from the CSV. Each except names the exact error it expects. A bare except: would also hide real bugs, like a typo in a variable name, which you'd much rather see.

Quick check
Why not catch both problems with a single bare except:?
  1. Python doesn't allow except without an error name
  2. It would catch nothing
  3. It would also hide unrelated bugs, and you couldn't tell a first run from a damaged file
  4. A bare except is slower
Show the answer

It would also hide unrelated bugs, and you couldn't tell a first run from a damaged file

A bare except catches everything, including mistakes you need to see. Naming each error lets you treat a missing file and a damaged one differently.

Worked example

Worked example: one short session

Three cards, all due in session 1. You answer y for a card you knew and n for one you didn't. The boxes move accordingly.

class Card:
    def __init__(self, front, back, box=1):
        self.front, self.back, self.box = front, back, box

cards = [Card("variable", "A name that holds a value"), Card("loop", "Code that repeats"),
         Card("function", "A named, reusable piece of code")]

score = 0
for card in cards:
    answer = input(f"{card.front}? (y = knew it, n = didn't) ").strip().lower()
    if answer == "y":
        card.box = min(card.box + 1, 3)
        score = score + 1
    else:
        card.box = 1
        print("  It means:", card.back)

print("Score:", score, "of", len(cards))
print([(c.front, c.box) for c in cards])
In the lesson you can change this and run it.

The loop shows each front and asks you to judge yourself, which is how most flashcard apps work. Typing the answer out would make checking hard: "a name for a value" and "a name that holds a value" mean the same thing. A missed card also shows its back, so the miss teaches you something. Anything other than y counts as a miss, which keeps the loop simple. The last line shows variable and function moved to box 2, and loop staying in box 1.

Go further

The trainer is complete. Some ways to extend it:

  • Shuffle the due cards, so the order changes each session.
  • Show a summary at the end of each session: how many cards are in each box.
  • Let the cards come from the practice API instead of a file, with one function that fetches them. The rest of the program shouldn't need to change.
  • Save after every answer, not just at the end, so closing the program halfway loses nothing.

The last idea is about durability: the less work a crash can lose, the better the program. That idea comes back, at a much bigger scale, when you store data in databases and run services that must not lose anything.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

The progress file doesn't exist yet. What should the trainer do?

  1. Start quietly from the CSV: it's simply the first run
  2. Crash with FileNotFoundError
  3. Print an error and stop
  4. Create an empty deck

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson