diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-30 13:17:47 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-30 13:17:47 -0500 |
| commit | 0234e52b727b34ade93961eb05b5638685f4406f (patch) | |
| tree | b7ee5f66a9fceb3fd4d9b1d2ba8c44e89dde76c5 /.ai/scripts/drill-deck-stats.py | |
| parent | 038d59b7e548d2323f43dcd92ba14cba876d840d (diff) | |
| download | rulesets-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.py | 151 |
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()) |
