generate-flashcards

star 1

Render an Anki .apkg deck from wiki pages for spaced-repetition study. LLM writes card pairs; genanki packages them. CSV sidecar is the re-ingestable source. Used by /generate flashcards. Not user-invocable directly — go through /generate.

RonanCodes By RonanCodes schedule Updated 4/24/2026

name: generate-flashcards description: Render an Anki .apkg deck from wiki pages for spaced-repetition study. LLM writes card pairs; genanki packages them. CSV sidecar is the re-ingestable source. Used by /generate flashcards. Not user-invocable directly — go through /generate. user-invocable: false allowed-tools: Bash(which *) Bash(brew *) Bash(pip *) Bash(pipx *) Bash(python3 *) Bash(git *) Bash(mkdir *) Bash(date *) Bash(cat *) Bash(sed *) Bash(grep *) Read Write Glob Grep

Generate Flashcards

Produce an Anki .apkg deck from wiki pages — front/back card pairs, each tagged with the source wiki page. Import into Anki desktop or mobile.

Artifact-first — output lands in vaults/<vault>/artifacts/flashcards/.

Usage (via /generate router)

/generate flashcards <topic> [--vault <name>] [--count <n>] [--difficulty easy|medium|hard] [--no-viewer]
  • --count — number of cards (default 20).
  • --difficulty — nudges card style. Easy = term↔definition; medium = concept↔application; hard = comparison and synthesis cards.
  • --no-viewer — skip scaffolding the companion FSRS web viewer. Default: the viewer is scaffolded alongside the .apkg so the user has a shareable link without needing Anki installed.

Pipeline

wiki pages  →  LLM writes cards.csv  →  genanki script →  .apkg deck

Step 1: Dependency Check

HAS_GENANKI=0
python3 -c "import genanki" 2>/dev/null && HAS_GENANKI=1

if [ "$HAS_GENANKI" = "0" ]; then
  echo "genanki not found. Installing via pipx…"
  if which pipx >/dev/null 2>&1; then
    pipx install genanki || pipx inject genanki genanki
  else
    pip install --user genanki
  fi
fi

Anki import itself requires no local Anki install — .apkg is just a SQLite file Anki reads on import.

Step 2: Resolve Vault + Topic

mapfile -t PAGES < <(.claude/skills/generate/lib/select-pages.sh "$VAULT_DIR" "$TOPIC")
HASH=$(.claude/skills/generate/lib/source-hash.sh "${PAGES[@]}")

Step 3: Write cards.csv

CSV over JSON because Anki's own import path is CSV-native, and because awk/cut make the sidecar the most diffable option for Phase 2E re-ingest.

Schema — 4 columns, RFC 4180 quoting:

front,back,source,tags
"What is attention in transformers?","A mechanism that computes a weighted sum over all tokens in a sequence, letting each position directly attend to every other.","wiki/concepts/attention.md","transformers attention"
"Why is self-attention O(n²)?","Because it computes pairwise scores between all token pairs — cost grows quadratically with sequence length.","wiki/concepts/self-attention.md","transformers attention scaling"

Card-writing rules the LLM follows:

  • front is a question or cue; back is a complete answer (1–3 sentences).
  • Atomic facts — one idea per card. Split compound facts across multiple cards.
  • Tags: always includes the vault name and the topic; optionally adds concept tags.
  • source is the wiki page path — mandatory. Used in the footer of every card.
  • Difficulty calibration:
    • easy — term ↔ definition, single fact.
    • medium — apply the concept to a scenario ("Given X, which pattern fits?").
    • hard — compare two concepts, or derive an implication (cloze-style).

Step 4: Build the Deck

Generation is a small Python helper. Either invoked inline:

PY_SCRIPT="/tmp/generate-flashcards-$$.py"
cat > "$PY_SCRIPT" <<'PY'
import csv, hashlib, sys, genanki
csv_path, out_path, deck_name, vault_name = sys.argv[1:5]

# Stable IDs derived from deck name so re-renders update the same deck in Anki.
deck_id  = int(hashlib.sha256(deck_name.encode()).hexdigest()[:8], 16) & 0x7fffffff
model_id = int(hashlib.sha256(b"llm-wiki-basic-v1").hexdigest()[:8], 16) & 0x7fffffff

model = genanki.Model(
    model_id, "llm-wiki basic (v1)",
    fields=[{"name": "Front"}, {"name": "Back"}, {"name": "Source"}],
    templates=[{
        "name": "Card 1",
        "qfmt": "{{Front}}",
        "afmt": "{{FrontSide}}<hr id=answer>{{Back}}<div style='margin-top:1em;color:#888;font-size:.85em'>source: {{Source}}</div>",
    }],
    css=".card{font-family:system-ui;font-size:20px;color:#e8eef6;background:#0b0f14;padding:1.2em}hr#answer{border:0;border-top:2px solid #e0af40;margin:1em 0}",
)

deck = genanki.Deck(deck_id, deck_name)
with open(csv_path, newline="") as f:
    reader = csv.DictReader(f)
    for row in reader:
        tags = (row.get("tags") or "").split() + [vault_name.replace(" ", "_")]
        deck.add_note(genanki.Note(
            model=model,
            fields=[row["front"], row["back"], row["source"]],
            tags=tags,
        ))
genanki.Package(deck).write_to_file(out_path)
PY

DECK_NAME="llm-wiki::$VAULT_NAME::$TOPIC_SLUG"
python3 "$PY_SCRIPT" "$CSV_PATH" "$APKG_OUT" "$DECK_NAME" "$VAULT_NAME"
rm "$PY_SCRIPT"

Or as a checked-in helper at .claude/skills/generate-flashcards/build_deck.py if you prefer not to heredoc each time. Same code either way.

Why stable deck IDs matter

Anki updates cards in-place when you re-import a deck with the same deck_id + model_id. That's the right default: edit cards.csv, re-run /generate flashcards, re-import in Anki, and your review history stays intact. Using random IDs would create a duplicate deck on every render — painful.

Step 5: Keep .cards.csv Alongside

cp "$CSV_PATH" "${APKG_OUT%.apkg}.cards.csv"

.apkg is a SQLite file and awkward to parse. The CSV is what Phase 2E's verify-artifact re-ingests for fidelity testing.

Step 5.5: Scaffold the Companion Viewer (opt-out via --no-viewer)

The .apkg is the portable export — desktop Anki, AnkiMobile, AnkiDroid. But Anki's UI is dated and requires an install. So by default we also scaffold a modern FSRS-backed web viewer alongside, using the shared flashcards-viewer template:

if [ "${NO_VIEWER:-0}" = "0" ]; then
  VIEWER_DIR="$VAULT_DIR/artifacts/app/flashcards-$TOPIC_SLUG-$(date +%Y-%m-%d)"
  TEMPLATE_DIR=".claude/skills/generate-app/templates/flashcards-viewer"

  mkdir -p "$VIEWER_DIR"
  cp -R "$TEMPLATE_DIR/." "$VIEWER_DIR/"

  # Convert cards.csv → data.json. Stable deck_id = sha256(DECK_NAME) so
  # re-renders preserve the viewer's localStorage review history the same
  # way Anki preserves its SQLite history via the stable .apkg deck_id.
  python3 - "$CSV_PATH" "$VIEWER_DIR/src/data.json" "$TITLE" "$TOPIC" "$DECK_NAME" <<'PY'
import csv, hashlib, json, sys, datetime
csv_path, out_path, title, topic, deck_name = sys.argv[1:6]
deck_id = hashlib.sha256(deck_name.encode()).hexdigest()[:16]
cards = []
with open(csv_path, newline="") as f:
    for i, row in enumerate(csv.DictReader(f)):
        cards.append({
            "id": f"c{i+1}",
            "front": row["front"],
            "back": row["back"],
            "source": row["source"],
            "tags": (row.get("tags") or "").split(),
        })
with open(out_path, "w") as f:
    json.dump({
        "title": title,
        "topic": topic,
        "deck_id": deck_id,
        "generated_at": datetime.datetime.utcnow().strftime("%Y-%m-%d"),
        "cards": cards,
    }, f, indent=2)
PY

  # Rename the scaffolded package so multiple decks don't collide
  sed -i.bak "s/\"flashcards-viewer\"/\"flashcards-$TOPIC_SLUG\"/" "$VIEWER_DIR/package.json"
  rm "$VIEWER_DIR/package.json.bak"

  VIEWER_SCAFFOLDED="$VIEWER_DIR"
fi

The user runs pnpm install && pnpm dev in the scaffolded directory to view the deck in a browser. The same deck_id scheme means the viewer's FSRS state (localStorage) and Anki's review history (SQLite) stay independent but parallel — change the card content, both re-ingest cleanly, both preserve history.

Step 6: Version Detection

Before writing the sidecar, check for an existing artifact of the same type and topic:

ARTIFACT_TYPE="flashcards"
EXISTING=$(ls "$VAULT_DIR/artifacts/$ARTIFACT_TYPE/"*"$TOPIC_SLUG"*.meta.yaml 2>/dev/null | sort | tail -1)
if [ -n "$EXISTING" ]; then
  PREV_VERSION=$(grep '^version:' "$EXISTING" | awk '{print $2}')
  PREV_VERSION=${PREV_VERSION:-1}
  VERSION=$((PREV_VERSION + 1))
  PREV_SLUG=$(basename "$EXISTING" .meta.yaml)
else
  VERSION=1
  PREV_SLUG=""
fi

The old artifact stays in place — not deleted, not overwritten. Multiple files of the same type + topic = version history. The portal discovers and displays these automatically.

Small fixes (CSS tweaks, typo corrections) should update the file in-place without incrementing the version — use judgement based on whether the content meaningfully changed.

Step 7: Write the Sidecar

META="${APKG_OUT%.apkg}.meta.yaml"
cat > "$META" <<EOF
generator: generate-flashcards@0.2.0
generated-at: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
topic: "<raw topic argument>"
difficulty: ${DIFFICULTY:-medium}
count: $COUNT
deck-name: $DECK_NAME
genanki-version: $(python3 -c "import genanki; print(genanki.__version__)")
viewer: ${VIEWER_SCAFFOLDED:-null}
generated-from:
$(for p in "${PAGES[@]}"; do echo "  - $p"; done)
source-hash: $HASH
version: $VERSION
change-note: "<brief description of what changed, or 'Initial version' for v1>"
replaces: "$PREV_SLUG"
EOF

Step 8: Commit to Vault Repo

cd "$VAULT_DIR"
git add "artifacts/flashcards/<slug>-<date>."{apkg,cards.csv,meta.yaml}
git diff --cached --quiet || git commit -m "🃏 flashcards: generate <topic> ($(date +%Y-%m-%d))"

Step 9: Report to User

✅ Flashcards generated
   Topic:       <topic>
   Deck:        llm-wiki::<vault>::<topic>
   Cards:       <N>
   Difficulty:  <easy|medium|hard>
   Source hash: <first 12 chars>
   APKG:        vaults/<vault>/artifacts/flashcards/<slug>-<date>.apkg
   CSV:         vaults/<vault>/artifacts/flashcards/<slug>-<date>.cards.csv
   Sidecar:     vaults/<vault>/artifacts/flashcards/<slug>-<date>.meta.yaml
   Viewer:      vaults/<vault>/artifacts/app/flashcards-<slug>-<date>/   (skipped with --no-viewer)

   Anki:        File → Import → select the .apkg
   Mobile:      AirDrop / share the .apkg to AnkiMobile / AnkiDroid
   Web viewer:  cd <viewer path> && pnpm install && pnpm dev

Importing into Anki

Desktop: File → Import… → pick the .apkg. Anki creates (or updates) the llm-wiki::<vault>::<topic> deck. Hierarchical name (:: separator) keeps vaults organised.

AnkiMobile / AnkiDroid: AirDrop / share-sheet the .apkg to the mobile app. Same update-in-place behaviour.

Second render, same topic: stable deck + model IDs mean Anki updates existing cards and adds new ones. Your review scheduling history survives.

Known Limitations (Phase 2D)

  • LLM-dependent card quality. Great wiki pages → great cards. Sparse pages → repetitive cards.
  • No cloze cards yet. Basic (front/back) only. Cloze deletion is a future card model — the model_id scheme leaves room.
  • No media support. Text-only cards. Adding image/audio fields would mean teaching the LLM to emit media refs and the script to package them — deferred.
  • CSV escaping is RFC 4180 — if the LLM writes a back containing a raw double-quote, it must be doubled (""). The prompt instructs this.

See Also

  • .claude/skills/generate/SKILL.md — router that dispatches here.
  • .claude/skills/generate-quiz/SKILL.md — one-shot self-test sibling.
  • genanki — the upstream library.
  • sites/docs/src/content/docs/reference/artifacts.md — sidecar schema.
Install via CLI
npx skills add https://github.com/RonanCodes/llm-wiki --skill generate-flashcards
Repository Details
star Stars 1
call_split Forks 1
navigation Branch main
article Path SKILL.md
Occupations
More from Creator