Blog 2019/7/24
#!/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