aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/flashcard-stats.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-19 18:40:22 -0500
committerCraig Jennings <c@cjennings.net>2026-07-19 18:40:22 -0500
commita14e43b566946ba45abf58f75eb0a445b5b67ef3 (patch)
tree64dfcd6358a4839b56275efbbe761573e22de15f /.ai/scripts/flashcard-stats.py
parenta143679a50f5d3a84aea3ed4effcb3ac9e5af69f (diff)
downloadrulesets-a14e43b566946ba45abf58f75eb0a445b5b67ef3.tar.gz
rulesets-a14e43b566946ba45abf58f75eb0a445b5b67ef3.zip
feat(flashcard): support multi-tag drill headings + --tag-filter / --guid-salt
Craig wanted a curated "DeepSat Fundamentals" deck: the ~100 most fundamental cards out of the 465-card deepsat.org deck, marked with a second org tag (:fundamental:) so they stay grep-able in the source. Both flashcard-to-anki.py and flashcard-stats.py keyed a card on a heading ending in exactly :drill:, so adding any second tag turned the heading into :fundamental:drill:, which that anchor didn't match. The 100 tagged cards would have silently dropped from the full-deck apkg and been undercounted by stats. That's the passing-gate-skips-your-file failure. CARD_RE now matches a trailing org tag block, and a heading is a card when drill is among its tags. The other tags ride along as Anki tags next to the section tag. Card bodies are bounded by any L1/L2 heading (HEADING_RE) rather than only a section or a drill heading. flashcard-to-anki.py gains --tag-filter <tag> (emit only cards carrying that org tag, the subset deck) and --guid-salt <s> (a distinct GUID space so the subset imports non-empty instead of colliding with the full deck on GUID and importing empty). No salt is unchanged, so the existing deck's GUIDs and SRS state are untouched. flashcard-stats.py gets the same CARD_RE/HEADING_RE broadening plus a drill-membership guard. This is the rulesets-canonical reconcile of a stopgap the work project ran locally. I re-derived it against the current canonical rather than copying the preserved files, which predate the #+TITLE deck-name fix (060a938) and would have reverted it. parse()'s third element is now the Anki-tag list rather than a single tag string. Only build() and the tests consumed it. The existing parse tests move to the list contract, and new tests cover multi-tag parsing, --tag-filter, the guid salt, and the stats count. A bats case guards that a :fundamental:drill: card still counts. The full deck's real 465/100 check needs the work deck and isn't runnable here. Verified end to end instead on a synthetic multi-tag deck (2 cards, --tag-filter fundamental returns 1) through the real genanki path.
Diffstat (limited to '.ai/scripts/flashcard-stats.py')
-rwxr-xr-x.ai/scripts/flashcard-stats.py12
1 files changed, 9 insertions, 3 deletions
diff --git a/.ai/scripts/flashcard-stats.py b/.ai/scripts/flashcard-stats.py
index 1fa5afb..cb580ac 100755
--- a/.ai/scripts/flashcard-stats.py
+++ b/.ai/scripts/flashcard-stats.py
@@ -35,7 +35,12 @@ import re
import sys
from pathlib import Path
-CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$")
+# A card is a level-2 heading whose trailing org tag block includes `drill`.
+# Group 1 is the front, group 2 the tag block — so a curated card multi-tagged
+# :fundamental:drill: still counts (it would silently drop under a :drill:$
+# anchor, undercounting the deck). HEADING_RE bounds a card's body.
+CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$")
+HEADING_RE = re.compile(r"^\*{1,2}\s")
ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b")
PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$")
PROP_END_RE = re.compile(r"^\s*:END:\s*$")
@@ -177,7 +182,8 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]:
n = len(lines)
while i < n:
m = CARD_RE.match(lines[i])
- if not m:
+ tags = [t for t in m.group(2).split(":") if t] if m else []
+ if not (m and "drill" in tags):
i += 1
continue
heading = m.group(1).strip()
@@ -188,7 +194,7 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]:
body_lines: list[str] = []
while i < n:
line = lines[i]
- if line.startswith("* ") or CARD_RE.match(line):
+ if HEADING_RE.match(line):
break
if PROP_START_RE.match(line):
prop_count += 1