aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/drill-deck-stats.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-30 13:17:47 -0500
committerCraig Jennings <c@cjennings.net>2026-05-30 13:17:47 -0500
commit0234e52b727b34ade93961eb05b5638685f4406f (patch)
treeb7ee5f66a9fceb3fd4d9b1d2ba8c44e89dde76c5 /.ai/scripts/drill-deck-stats.py
parent038d59b7e548d2323f43dcd92ba14cba876d840d (diff)
downloadrulesets-0234e52b727b34ade93961eb05b5638685f4406f.tar.gz
rulesets-0234e52b727b34ade93961eb05b5638685f4406f.zip
chore(scripts): add drill-deck stats, diff-ids, and sync wrapper
I incorporated the flashcard-tooling bundle from the work project's deck-review workflow, validated there against a 93-card deck. Three scripts now live under .ai/scripts/: drill-deck-stats.py (pre-rewrite inventory plus a gate that warns on stray *** Answer headers, missing :ID:, non-prompt headings, and #+TITLE jargon like "org-drill"), drill-deck-diff-ids.py (SRS-state preservation check that flags any :ID: lost across a rewrite), and drill-deck-sync (bash wrapper chaining stats, optional diff-ids, then drill-to-anki, writing to ~/sync/phone/anki/ only when the gates pass). The drill-deck-review.org workflow gains a Helper Scripts section and references the scripts from its phases. I reconciled its output-path prose with the drill-to-anki default that just moved to ~/sync/phone/anki/, so it no longer claims the script still defaults to ~/sync/org/drill/. I added tests for both Python scripts (pure logic plus CLI gate behavior) and a bats suite for the wrapper's guard paths. The clean end-to-end sync path stays uncovered since it needs uv-resolved genanki.
Diffstat (limited to '.ai/scripts/drill-deck-stats.py')
-rwxr-xr-x.ai/scripts/drill-deck-stats.py151
1 files changed, 151 insertions, 0 deletions
diff --git a/.ai/scripts/drill-deck-stats.py b/.ai/scripts/drill-deck-stats.py
new file mode 100755
index 0000000..72d1cde
--- /dev/null
+++ b/.ai/scripts/drill-deck-stats.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Inventory + workflow-violation warnings for an org-drill deck source file.
+
+Reports counts and flags violations:
+- Total cards (depth-2 `:drill:` headings)
+- PROPERTIES drawer count (should match card count)
+- `*** Answer` sub-header count (should be 0 per drill-deck-review.org)
+- Cards missing :ID: (loses identity across versions, risks SRS-state loss)
+- Cards whose heading lacks `?` (likely a topic-as-heading not yet rewritten)
+
+Exits 0 when clean, 1 when any warnings are present. Use as a gate before
+regenerating the Anki deck or running drill-deck-sync.
+
+Usage:
+ drill-deck-stats.py <file.org>
+"""
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\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*$")
+ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$")
+TITLE_RE = re.compile(r"^#\+TITLE:\s*(.+?)\s*$", re.IGNORECASE)
+SOURCE_TOOL_RE = re.compile(r"\borg[-\s]?drill\b", re.IGNORECASE)
+
+# A heading qualifies as "prompt form" if it contains `?` or starts with
+# one of these imperative verbs (directive prompts like "Spell these out"
+# and "Introduce yourself" are valid even without `?`).
+IMPERATIVE_VERBS = frozenset({
+ "spell", "describe", "explain", "name", "list", "give",
+ "show", "tell", "define", "compare", "identify", "outline",
+ "introduce", "walk", "state", "recite", "recall", "summarize",
+})
+
+
+def is_prompt_form(heading: str) -> bool:
+ """True if the heading reads as a question or imperative prompt."""
+ if "?" in heading:
+ return True
+ first_word = heading.split(None, 1)[0].lower().rstrip(":,;")
+ return first_word in IMPERATIVE_VERBS
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print(f"usage: {sys.argv[0]} <file.org>", file=sys.stderr)
+ return 2
+
+ path = Path(sys.argv[1]).expanduser().resolve()
+ if not path.is_file():
+ print(f"error: {path} not found", file=sys.stderr)
+ return 2
+
+ lines = path.read_text(encoding="utf-8").splitlines()
+
+ title: str | None = None
+ for line in lines[:20]:
+ m = TITLE_RE.match(line)
+ if m:
+ title = m.group(1).strip()
+ break
+
+ cards: list[tuple[str, bool, bool]] = [] # (heading, has_id, has_answer_subheader)
+ answer_count = 0
+ prop_count = 0
+
+ i = 0
+ while i < len(lines):
+ m = CARD_RE.match(lines[i])
+ if m:
+ heading = m.group(1).strip()
+ i += 1
+ has_id = False
+ has_answer = False
+ in_drawer = False
+ while i < len(lines):
+ line = lines[i]
+ if line.startswith("* ") or CARD_RE.match(line):
+ break
+ if PROP_START_RE.match(line):
+ prop_count += 1
+ in_drawer = True
+ elif in_drawer and PROP_END_RE.match(line):
+ in_drawer = False
+ elif in_drawer and ID_RE.match(line):
+ has_id = True
+ elif ANSWER_RE.match(line):
+ answer_count += 1
+ has_answer = True
+ i += 1
+ cards.append((heading, has_id, has_answer))
+ continue
+ i += 1
+
+ not_prompt = [h for h, _, _ in cards if not is_prompt_form(h)]
+ no_id = [h for h, has_id, _ in cards if not has_id]
+
+ print(f"{path.name} — drill deck stats")
+ print()
+ title_display = title if title else "(no #+TITLE)"
+ print(f"Deck title: {title_display}")
+ print(f"Cards: {len(cards)}")
+ drawer_status = "match" if prop_count == len(cards) else f"mismatch (expected {len(cards)})"
+ print(f"PROPERTIES drawers: {prop_count} ({drawer_status})")
+ answer_status = "clean" if answer_count == 0 else "workflow violation"
+ print(f"*** Answer sub-headers: {answer_count} ({answer_status})")
+ print(f"Cards missing :ID:: {len(no_id)}")
+ print(f"Cards with non-prompt heading: {len(not_prompt)}")
+ print()
+
+ warnings = 0
+ if title is None:
+ warnings += 1
+ print("WARN: no #+TITLE: line found; deck name will fall back to the file basename")
+ elif SOURCE_TOOL_RE.search(title):
+ warnings += 1
+ print(f"WARN: #+TITLE contains source-tool jargon ('{title}'); the deck name shows in Anki — drop 'Org-Drill' for a name that reads well on the consumption side")
+ if answer_count:
+ warnings += 1
+ print(f"WARN: {answer_count} cards have *** Answer sub-headers (drop per drill-deck-review.org)")
+ if prop_count != len(cards):
+ warnings += 1
+ print(f"WARN: PROPERTIES count {prop_count} does not match card count {len(cards)}")
+ if no_id:
+ warnings += 1
+ print(f"WARN: {len(no_id)} cards missing :ID:; losing identity risks SRS-state loss across rewrites")
+ for h in no_id[:5]:
+ print(f" - {h}")
+ if len(no_id) > 5:
+ print(f" - ... and {len(no_id) - 5} more")
+ if not_prompt:
+ warnings += 1
+ print(f"WARN: {len(not_prompt)} cards have non-prompt headings (no '?' and no imperative-verb start); likely topic-as-heading not yet rewritten")
+ for h in not_prompt[:5]:
+ print(f" - {h}")
+ if len(not_prompt) > 5:
+ print(f" - ... and {len(not_prompt) - 5} more")
+
+ if warnings == 0:
+ print("clean")
+ return 0
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())