diff options
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()) |
