Skip to content

Instantly share code, notes, and snippets.

@cellularmitosis
Last active October 14, 2019 08:17
Show Gist options
  • Save cellularmitosis/bab425e7dd99046e79ca7d8e32f830dd to your computer and use it in GitHub Desktop.
Save cellularmitosis/bab425e7dd99046e79ca7d8e32f830dd to your computer and use it in GitHub Desktop.

Blog 2019/7/24

srs.py: a work-in-progress command-line spaced-repetition learning system

#!/usr/bin/env python
"""srs.py: a simple command-line spaced-repetition learning system."""
# see also https://github.com/pepasflo/whodis
# Example deck file (~/srs/jokes.md):
'''
# Jokes
A deck of jokes
# q
Why did the chicken cross the road?
---
To get to the other side!
# q
What do you call a cow with no legs?
---
Ground beef!
'''
# A vocabulary deck:
'''
# Spanish
A deck of Spanish vocabulary.
# q
Biblioteca
---
Library
# q
Ventana
---
Window
'''
# A more complex example:
'''
# Misc
A deck of random things.
# q
Who said
> We have nothing to fear but fear itself
---
FDR
# q
What might a linked-list node look like in Python?
---
```
class ListNode:
value = None
next = None
```
# q
Who painted this?
[https://upload.wikimedia.org/wikipedia/en/4/4a/No._5%2C_1948.jpg]()
---
Jackson Pollock
'''
# A sqlite database is used to track statistics about questions / answers.
# Each question will be assigned a uuid the first time it is encountered,
# (the markdown file will be edited to insert the UUID)
# and will be referred to in the database by that key.
# (The deck will also be assigned a UUID).
# i.e. after running srs.py, the above deck file might look like:
'''
# Jokes 9DF8162E-9767-4363-BF24-5BED99D8DC39
...
## Q 7302CD07-B3ED-41CB-8327-C52E29C5FC9D
What do you call a cow with no legs?
## A
Ground beef!
'''
# Algorithm for choosing the next card to show the user:
# We us something similar to the Leitner algorithm.
# For each card, keep track of a score and a last-seen timestamp.
# A score of 0 means (and a NULL timestamp) "this card has never been seen".
# A correct answer increments the score by 1.
# An incorrect answer decrements the score by 1.
# The score "truncates" when switching sign.
# That is, an incorrect answer causes any positive score to "jump" to -1
# and vice-versa (a correct answer jumps any negative score to +1).
# An answer sequence of "(never seen), correct, correct, correct, incorrect"
# would result in a score history of "0, 1, 2, 3, -1".
# The last-seen timestamp also affects card selection.
# - Cards which haven't been seen during this session are prioritized
# - Among these cards, the most negative score are prioritized
# - Then, unseen cards are prioritized
# - Finally, cards with a positive score are prioritized
# - After all cards which haven't been seen during this session are exhausted,
# the user will be shown cards which they have already seen today,
# - starting with the most negative score
# - in order of least-recently seen (among cards with the same score)
# - Additionally, the same card is never shown twice in a row, no matter what.
import sys
import os
import sqlite3
import time
import uuid
import re
dbpath = os.path.join(os.path.expanduser("~"), ".srs", "db.sqlite3")
def get_db():
"""Return a connection to the SRS stats database (creating it if needed)."""
if os.path.exists(dbpath):
return sqlite3.connect(dbpath)
else:
sql = '''
CREATE TABLE IF NOT EXISTS srs_stats_v1 (
card_id TEXT NOT NULL UNIQUE PRIMARY KEY,
deck_id TEXT,
score INTEGER NOT NULL, -- +1 per correct, -1 per incorrect answer
last_seen INTEGER -- unix timestamp, null means "never seen"
);
'''
conn = sqlite3.connect(dbpath)
cur = conn.cursor()
cur.execute(sql)
cur.close()
return conn
def next_card(conn, session_start, deck_id=None):
"""Determine which card would be best to show the user next."""
if deck_id is not None:
sql = '''
-- Get the cards (from deck_id) which have'nt been seen in this session, lowest score first.
SELECT card_id, score
FROM srs_stats_v1
WHERE deck_id = :deck_id
AND last_seen < :session_start
ORDER BY score ASC, last_seen ASC, card_id ASC
LIMIT 1;
'''
else:
sql = '''
-- Get the cards which have'nt been seen in this session, lowest score first.
SELECT card_id, score
FROM srs_stats_v1
WHERE last_seen < :session_start
ORDER BY score ASC, last_seen ASC, card_id ASC
LIMIT 1;
'''
cur = conn.cursor()
result = cur.execute(sql)
return result
def find_decks():
"""Return a list of paths to all deck files"""
deckspath = os.path.join(os.path.expanduser("~"), "srs")
deckfiles = [f for f in os.listdir(deckspath) if f.endswith(".md")]
return [os.path.join(deckspath, d) for d in deckfiles]
uuid_regex = re.compile(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}')
def line_contains_uuid(line):
return uuid_regex.search(line) != None
def ensure_deck_uuid(lines):
"""If the deck doesn't have a deck UUID, add one."""
for i, line in enumerate(lines):
if line.startswith("# "):
if not line_contains_uuid(line):
lines[i] += " %s" % uuid.uuid1()
break
def ensure_card_uuids(lines):
"""If any card in the deck doesn't have a UUID, add one."""
for i, line in enumerate(lines):
if line.startswith("## Q") or line.startswith("# q"):
if not line_contains_uuid(line):
lines[i] += " %s" % uuid.uuid1()
break
def parse_deck(deckfpath):
with open(deckfpath) as fd:
lines = fd.readlines()
def make_uuid():
return uuid.uuid1().hex
if __name__ == "__main__":
session_start = int(time.time())
print find_decks()
sys.exit(0)
if len(sys.argv) > 1:
decks = sys.argv[1:]
else:
pass
# TODO:
# - open all decks and scan for items without UUIDs
# - generate UUIDs and edit the decks
# - start the loop of asking the user questions
# - update the DB for each answer
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment