{"slug": "building-anki-cards-with-claude-code", "title": "Building Anki Cards with Claude Code", "summary": "A physician built a 1,386-card Anki deck for cardiac electrophysiology board review using Claude Code, Python, genanki, and pdftotext in about a day, and discovered that 16 of the first 52 cards were broken because Anki's HTML parser stripped numeric cutoffs like '<250 msec' from card text. The fix was a nine-character escape function in the packaging script, and the author recommends deriving deck IDs from deck names to preserve review history across rebuilds.", "body_md": "# Building Anki Cards with Claude Code\n\nI sit the clinical cardiac electrophysiology boards this year, and I own the usual pile of review material: a question-and-commentary product with 463 numbered items, twenty-three lecture decks, eighteen workshop question sets. Reading it is not the problem. Recalling a pre-excitation cutoff or an entrainment threshold months from now is the problem, and that is what spaced repetition solves.\n\nThe obvious approach is one flashcard per numbered item. I tried it, and it produces a deck that teaches you the question bank instead of the subject. What I wanted was a deck built the way I build study notes: pull the same fact from six places in the corpus, reconcile the versions where they disagree, and write one dense card.\n\nI ended up with **468 notes generating 1,386 cards across 13 subdecks**, built with Claude Code over about a day. The tooling is ordinary. Python, `genanki`\n\nfor packaging, `pdftotext`\n\nfor extraction, no fine-tuning, no vector database, and no API bill beyond a Claude subscription.\n\nThe deck is not the interesting part. The interesting part is what the build caught. My first batch of 52 cards imported into Anki with no errors and no warnings, and sixteen of them were broken.\n\n## The Bug That Would Have Poisoned Everything\n\nA card reading `SPERRI <250 msec identifies a high-risk pathway`\n\nrenders in Anki as `SPERRI`\n\n.\n\nAnki treats fields as HTML. An HTML parser reads `<250 msec identifies a high-risk pathway`\n\nas an unclosed tag and discards everything up to the next `>`\n\n. My deck is built around numeric cutoffs, so nearly a third of that first batch had lost its number, and nothing in the toolchain complained. I would have found out in November, drilling cards that had been gutted since August.\n\nThe fix belongs in the packaging script rather than the card text, so that it protects every card written afterward:\n\n``` php\ndef esc(text: str) -> str:\n    \"\"\"Escape < and > so Anki does not parse cutoffs like \"<115 msec\" as HTML.\n\n    Card text is plain cloze markup, never HTML, so this is unconditional.\n    Without it, Anki swallows any \"<...\" run and the cutoff vanishes from the\n    rendered card, a failure that is invisible until review time.\n    \"\"\"\n    return text.replace('<', '&lt;').replace('>', '&gt;')\n```\n\nNine characters of code.\n\nThe general version: whatever renders your cards has opinions about your text and will not tell you what they are. If your subject involves comparison operators, chemical formulas, or anything angle-bracketed, render a sample and look at it before you build a thousand more.\n\n## Packaging, and Why Deck IDs Matter\n\nThe build stage is `genanki`\n\nand about sixty lines. Two decisions in it are worth copying.\n\nThe note type carries two fields. `Text`\n\nholds the cloze markup and `Extra`\n\nrenders on the answer side only, which makes it free real estate for mechanism, citations, and the reason a distractor fails. None of it costs a recall test.\n\n```\nCLOZE_MODEL = genanki.Model(\n    998877661,                      # fixed ID so re-imports map to the same note type\n    'EP Cloze Model',\n    fields=[{'name': 'Text'}, {'name': 'Extra'}],\n    templates=[{\n        'name': 'Cloze',\n        'qfmt': '{{cloze:Text}}',\n        'afmt': '{{cloze:Text}}<br><br>'\n                '<div style=\"color:#666;font-size:0.9em\">{{Extra}}</div>',\n    }],\n    model_type=genanki.Model.CLOZE,\n)\n```\n\nThe second decision I made was to derive the deck ID from the deck name instead of letting `genanki`\n\npick a random integer:\n\n``` php\ndef deck_id_from_name(name: str) -> int:\n    return int(hashlib.md5(name.encode('utf-8')).hexdigest()[:8], 16)\n```\n\nA random ID means every rebuild imports as a brand new deck and your review history stays behind in the old one. Hashing the name means `EP Board Review::Devices & Programming`\n\nresolves to the same ID forever, so a rebuild updates in place. The `::`\n\nseparator gives you subdecks, which is how one master deck grows across many source PDFs over months.\n\n## Why Not Just Ask ChatGPT?\n\nThis is the obvious question, and I tried the obvious thing first. Paste a chapter into a chat window, ask for cloze deletions, copy the result into Anki. It works, and it produces a deck with four problems.\n\n**It cards the source, not the subject.** One flashcard per question teaches you the question bank. Real understanding needs the same fact pulled from six places in the corpus with the versions reconciled. A chat window holds one chapter at a time and has no memory of the other five mentions.\n\n**It duplicates.** Anki deduplicates on exact first-field match, so two differently worded cards teaching the same fact both import without complaint. You review the same fact twice for weeks before noticing.\n\n**It cannot check its own work.** A chat model will tell you the cards look good. It will not open the packaged file, render the fields, and discover that a third of them lost their numbers.\n\n**It cannot go to the source.** When my review product paraphrased a guideline wrong, catching it required fetching the actual guideline PDF, extracting the recommendation table, and comparing the text.\n\nThe difference is the agent loop. Claude Code reads files, writes files, runs code, and checks its own output against a standard I wrote down. Everything below depends on that loop, and none of it works in a chat window.\n\n## Give Every Fact a Machine-Checkable ID\n\nPass one walks the corpus and emits one JSON line per extracted fact. Each carries a `fact_key`\n\n: a normalized `topic::parameter`\n\nstring.\n\n```\n{\"item\": 1,\n \"fact_key\": \"af_ablation::early_recurrence_predictive_value\",\n \"fact\": \"Early recurrence after AF ablation: pooled NPV for late recurrence 89% paroxysmal / 91% persistent; PPV more variable (59.7% vs 81.2%)\",\n \"categories\": [\"pearls\", \"cutoffs\"],\n \"numeric\": [{\"param\": \"NPV early recurrence\", \"value\": \"89% parox / 91% persistent\"},\n             {\"param\": \"PPV early recurrence\", \"value\": \"59.7% parox / 81.2% persistent\"}],\n \"figure_dependent\": false,\n \"src\": \"epsap\"}\n```\n\nEvery field earns its place. `numeric`\n\nis broken out separately so numbers can be checked against the source without parsing prose. `figure_dependent`\n\nflags facts that reference a tracing the extractor cannot see, which is how I learned that half the corpus was unusable for text cards. `src`\n\nmatters once a second source enters the build.\n\nSix hundred forty-nine facts came out of that pass. Sorting by `fact_key`\n\nclusters every mention of a parameter across the whole corpus, which turns an invisible contradiction spread across 400 pages into a visible one in a single grep. Four numeric conflicts surfaced. Each now lives on one card that teaches the majority value first and names the minority framing, with the item numbers in the notes field.\n\nThe same key solves the duplication problem. Before writing any card in a later phase, the build checks the key against the index. A hit permits three moves: enrich the existing note, reconcile a numeric conflict, or skip. Writing a new note is not among them. Two later phases added 104 notes and produced zero duplicates.\n\nIf you build a deck across more than one sitting, this is the piece to steal.\n\n## Grade the Deck Against Real Questions\n\nAfter the first phase produced 356 notes, the temptation was to keep building. Instead the build stops for a checkpoint that writes no cards.\n\nThe question sets are lecture slides, so there are no question numbers to split on. What every question does have is an option block, which makes the parse a scan for three or more consecutive `A.`\n\nthrough `E.`\n\nlines:\n\n```\nOPT = re.compile(r'^\\s*([A-E])\\s*[\\.\\)]\\s*\\S')\n...\nif len(set(letters)) >= 3 and letters[0] == 'A':\n    stem = '\\n'.join(lines[max(0, a - 14):a])   # 14 lines above the options\n    exp  = '\\n'.join(lines[b:next_block])       # everything until the next one\n```\n\nEach stem appears twice in these decks, once on the question slide and once on the answer slide, so a dedup step keys on the normalized last 300 characters of the stem and keeps whichever copy carries the longer explanation. That yielded **225 discrete questions** across eighteen sets.\n\nThe sample is drawn by stride rather than randomly:\n\n```\nidx = [round(i * len(questions) / 50) for i in range(50)]\n```\n\nDeterministic, spread across every set in proportion to its size, and reproducible. Shifting the offset later gives a genuinely independent second sample, which matters because I plan to re-score the deck after the next build phase.\n\nEach question got one grade: does the deck contain the fact that discriminates the right answer from the distractors?\n\n| Grade | n | % |\n|---|---|---|\n| answered | 28 | 56% |\n| partial | 13 | 26% |\n| missing | 9 | 18% |\n\n**56%.**\n\nMy own review of my own cards told me the deck was in good shape. Fifty real questions told me it had holes and named them: the wavelength of a reentrant circuit, Coumel's sign, cryoablation technique for AV nodal reentry, the genetics counselling workflow. Not one had occurred to me.\n\nThe distribution mattered more than the score. Entrainment and pacing manoeuvres scored 17 of 19. Conduction system pacing had **one card in the entire deck** against a 111-page lecture, on a topic modern boards lean on. That single measurement redirected the next phase.\n\nA second finding: 30 of the 50 questions required reading a tracing. The deck answered many of them because the discriminating fact is a principle and the tracing is the vehicle, but waveform interpretation stays out of reach for a text deck.\n\nIf you build a deck with AI and never score it against real questions, you are grading your own homework.\n\n## Coverage and Card Quality Are Different Axes\n\nI read a few hundred cards and found some of them worthless. This one is representative:\n\nAtenolol has {{c1::\n\nless}} protein binding than other beta-blockers and therefore more potential for beta-blocker-related adverse effects in pregnancy.\n\nThe word \"therefore\" hands you the answer. Worse, the fact worth testing, that atenolol is the beta-blocker to avoid in pregnancy, sits in plain text where nothing tests it.\n\nA card like that scores `answered`\n\non the coverage check. The validation checkpoint is blind to this failure by construction, because it asks whether the deck contains a fact, not whether the card tests it.\n\nOne class of bad card is machine-detectable, and worth catching mechanically because it is invisible on a read-through: a cloze whose answer appears somewhere else in the same note, in plain text or inside a different cloze.\n\n``` python\nCLOZE = re.compile(r'\\{\\{c(\\d+)::(.*?)\\}\\}')\n\ndef giveaways(text):\n    \"\"\"Yield clozes whose answer is visible elsewhere in the same note.\"\"\"\n    for num, answer in CLOZE.findall(text):\n        # Same-numbered clozes hide together, so blank those; reveal the rest.\n        shown = CLOZE.sub(\n            lambda m: '' if m.group(1) == num else m.group(2), text)\n        a = answer.strip()\n        if len(a) > 3 and re.search(\n                rf'(?<![A-Za-z]){re.escape(a)}(?![A-Za-z])', shown, re.I):\n            yield num, a\n```\n\nThe subtlety is the same-number carve-out. Clozes sharing an index are hidden together during review, so `{{c1::fast}}`\n\nappearing twice is fine, while `{{c1::fast}}`\n\nalongside `{{c2::slow-fast AVNRT}}`\n\nis a leak. My first version of this check used substring matching without word boundaries and reported clean. The boundary-aware version found 23 real leaks I had already declared fixed.\n\nThat scan is where mechanical detection stops. Run against the full deck it produced 65 candidates, most of them false positives, because `IKr`\n\nand `H-H`\n\nare short answers and good ones. It also missed the atenolol card entirely, since nothing in that sentence repeats the word \"less.\" The defect is semantic. So Claude Code read all 1,184 clozes one at a time against a written test:\n\nCould a smart person who has never studied this subject answer it from the sentence alone?\n\nSixty-two clozes failed, or 5.2%. Six named failure modes came out of it, and they are the same six in any subject:\n\n| Failure | Signature |\n|---|---|\n| Inferable from stem | The sentence's own logic forces the answer |\n| Wrong half clozed | The hard fact sits in plain text |\n| Redundant | Same fact hidden twice in one note |\n| Coin-flip directional | higher/lower with no memory hook |\n| Ungradeable | A ten-word list you cannot honestly self-score |\n| Trivia | Enrollment counts, dates nobody tests |\n\n## Prose Cards Fail Fifteen Times More Often Than Number Cards\n\nThis is the finding I would carry to any subject.\n\n| Deck | Weak clozes | Rate |\n|---|---|---|\n| Drug Interactions | 5 / 27 | 19% |\n| Clinical Pearls | 11 / 62 | 18% |\n| Risk Factors | 4 / 124 | 3% |\n| Ablation Biophysics | 2 / 150 | 1% |\n\nThe prose-heavy decks failed at roughly fifteen times the rate of the number-heavy ones, and the reason is mechanical. A cutoff of 250 msec cannot be inferred from a sentence. A clause beginning \"and therefore\" always can. Numbers resist the failure mode. Explanations invite it, because good explanatory prose makes its conclusion follow from its premises, which is the opposite of what a test item needs.\n\nIf you write cloze cards on conceptual material, that is where your bad cards live. Check those first.\n\nThe repair was rewriting rather than deletion. In almost every case the fact was fine and only the hiding was wrong, so 56 cards got new sentences and six lost a redundant cloze.\n\n## Where the Source Itself Was Wrong\n\nThree cards carried claims my review product had garbled. Finding them required going to the primary documents.\n\n**A pacing target.** My source paraphrased a European guideline as recommending biventricular pacing above 95%. The guideline text reads that junction ablation should be added for incomplete pacing below **90 to 95%**, Class IIa. Close enough to sound right, wrong enough to lose a question.\n\n**An anticoagulation recommendation.** The source cites a 2019 update that gave three drugs a Class III in end-stage kidney disease. The 2023 guideline supersedes it. No Class III survives, the whole question drops to Class IIb, and one of those three drugs remains listed at a reduced dose. The old card taught me to eliminate a drug that current guidance permits.\n\n**Sports participation.** The 2015 disqualification framework, which most prep material still teaches, was replaced in 2025 by shared decision making. Several restrictions I would have memorized no longer exist.\n\nThe pattern: a review product freezes at its publication date and paraphrases toward whatever sounds cleaner. Verification against the primary document is slow, and it caught three cards that would have taught me confident wrong answers.\n\n## Does This Work for Other Subjects?\n\nNothing above is specific to medicine. The method needs three things:\n\n**A corpus you can turn into text.** PDFs with a text layer, transcripts, notes. My nine image-only lecture decks (604 pages) produced nothing, and that limitation is absolute.**A way to name facts.** The`topic::parameter`\n\nconvention works for anything with parameters. Pharmacology, statutes, language grammar rules, engineering constants.**A held-out set of real questions.** Without something to score against, you have no feedback signal and you are back to grading your own homework.\n\nWhat transfers least is the part I spent the most time on, which is domain judgment about what matters. The AI proposed the cards. Deciding that conduction system pacing deserved 38 of them and adult congenital heart disease deserved fewer required knowing the field.\n\n## The Build Loop\n\nEvery phase ends the same way. One script walks every note and checks four things: cloze numbering is contiguous from 1, no cloze leaks its answer, every note carries provenance in `Extra`\n\n, and nothing is empty. It prints one line, and that line is the gate.\n\n``` bash\n$ python3 qc.py cards_*.json\nnotes=468 cards=1386 issues=0\n\n$ .venv/bin/python build_deck.py cards_*.json ep_board_review.apkg\nWrote 468 cards across 13 deck(s) to ep_board_review.apkg\n```\n\nA failing run names the card by deck and index, which is what makes the fix cheap:\n\n```\nGIVEAWAY  03_pacing_maneuvers 27 c1 :: fast\nGAP       10_devices 61 [1, 2, 4]\nTHIN      09_guidelines 29\n```\n\nTwo exports come out of the same pass: the `.apkg`\n\nfor import, and a CSV carrying deck, a stable reference like `10_devices#32`\n\n, the card text, the extra field, and a blank verdict column. Reading 1,386 cards in a spreadsheet and flagging them by reference is a very different task from reading them in Anki one at a time.\n\n## What I Have Not Proven\n\nI have not sat the exam. I have no retention data, no comparison against a commercially built deck, and no evidence that a synthesized card outperforms a transcribed one on recall. What I have is a deck whose every card has been checked against a written standard by something with more patience than I have.\n\nTwo known gaps remain. Waveform interpretation needs image occlusion cards from 604 pages holding no extractable text. And the deck is owed a second validation round on a fresh question sample, to see whether 56% moved.\n\n## Lessons\n\n**Ask for a critique of the plan before asking for execution.** Three errors in my build plan, including a worked example teaching the wrong number, surfaced before any card existed.**Give every fact a machine-checkable ID.** Clustering by`topic::parameter`\n\nturned four silent contradictions into visible ones and made later phases safe to add without duplicating.**Render a sample before building a thousand.** A comparison operator is an unclosed HTML tag. Sixteen of my first 52 cards were broken with no error.**Score against real questions, not your own review.** My review said the deck was good. Fifty questions said 56% and named four gaps I would never have listed.**Coverage and card quality are different axes.** A card can contain the right fact and test nothing. Read every cloze against \"could someone outside the field answer this from the sentence alone.\"**Prose cards fail fifteen times more often than number cards.** The word \"therefore\" is where a testable fact goes to die.**Go to the primary document for anything versioned.** Review products freeze at their publication date. Three cards in my deck taught outdated guidance.**Let the measurement tell you to stop.** The same data that told me to build 29 more device cards told me to build zero more congenital ones.\n\nThe generation is the easy half. Everything that made the deck worth keeping happened after the cards existed.", "url": "https://wpnews.pro/news/building-anki-cards-with-claude-code", "canonical_source": "https://segar.me/blog/posts/anki_cards_claude_code.html", "published_at": "2026-08-25 18:54:01+00:00", "updated_at": "2026-08-25 19:16:02.494893+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Claude Code", "Anki", "genanki", "pdftotext", "Python"], "alternates": {"html": "https://wpnews.pro/news/building-anki-cards-with-claude-code", "markdown": "https://wpnews.pro/news/building-anki-cards-with-claude-code.md", "text": "https://wpnews.pro/news/building-anki-cards-with-claude-code.txt", "jsonld": "https://wpnews.pro/news/building-anki-cards-with-claude-code.jsonld"}}