From ddf48dc7ac780da1aacdff4e03f1d7da255b8f39 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 31 May 2026 12:19:34 -0500 Subject: feat: add rename-ai-artifact tool and rename the drill-deck family to flashcard Renaming an .ai artifact by hand is the kind of mechanical job that gets done incompletely: the canonical copy moves but the mirror doesn't, a reference in the INDEX is missed, a trigger phrase points at the old name. I'd also assumed a rename was costly because references scatter, when the index update is trivial and the drift check already guards it. So I built the discipline into a script instead of re-deriving it each time. scripts/rename-ai-artifact.sh takes old and new basenames, moves the file in both the canonical and mirror trees, and rewrites every reference repo-wide on a token boundary so renaming "foo" can't corrupt "foobar" or "foo-bar". It rewrites the underscore module-name variant too (a hyphenated script imported as foo_bar via importlib), leaves the archived session records under sessions/ alone because they're history, and runs workflow-integrity + sync-check at the end to prove no drift. rename-artifact.org documents it and indexes the triggers. Then I used the tool to do the rename that prompted it: the org-drill deck workflow and its helpers are now flashcard-named, since "flashcard" is the word you'd actually search for. The renamed set is flashcard-review.org plus flashcard-stats.py, flashcard-sync, flashcard-to-anki.py, and flashcard-diff-ids.py, with their tests, every reference, and the INDEX entry updated. The deck is still an org-drill deck under the hood, so the ":drill:" tag handling and the "drill deck" trigger phrases stay. I added "review/update the flashcards" alongside them. Tests: 9 bats for the rename tool (including the prefix-collision and history-preservation edges), and the renamed script suites all pass under make test. --- .ai/scripts/drill-deck-diff-ids.py | 99 ------ .ai/scripts/drill-deck-stats.py | 327 ------------------ .ai/scripts/drill-deck-sync | 98 ------ .ai/scripts/drill-to-anki.py | 232 ------------- .ai/scripts/flashcard-diff-ids.py | 99 ++++++ .ai/scripts/flashcard-stats.py | 327 ++++++++++++++++++ .ai/scripts/flashcard-sync | 98 ++++++ .ai/scripts/flashcard-to-anki.py | 232 +++++++++++++ .ai/scripts/tests/drill-deck-sync.bats | 38 --- .ai/scripts/tests/flashcard-sync.bats | 38 +++ .ai/scripts/tests/test_drill_deck_diff_ids.py | 109 ------ .ai/scripts/tests/test_drill_deck_stats.py | 379 --------------------- .ai/scripts/tests/test_drill_to_anki.py | 171 ---------- .ai/scripts/tests/test_flashcard_diff_ids.py | 109 ++++++ .ai/scripts/tests/test_flashcard_stats.py | 379 +++++++++++++++++++++ .ai/scripts/tests/test_flashcard_to_anki.py | 171 ++++++++++ .ai/workflows/INDEX.org | 6 +- .ai/workflows/drill-deck-review.org | 327 ------------------ .ai/workflows/flashcard-review.org | 327 ++++++++++++++++++ .ai/workflows/rename-artifact.org | 44 +++ .../.ai/scripts/drill-deck-diff-ids.py | 99 ------ claude-templates/.ai/scripts/drill-deck-stats.py | 327 ------------------ claude-templates/.ai/scripts/drill-deck-sync | 98 ------ claude-templates/.ai/scripts/drill-to-anki.py | 232 ------------- claude-templates/.ai/scripts/flashcard-diff-ids.py | 99 ++++++ claude-templates/.ai/scripts/flashcard-stats.py | 327 ++++++++++++++++++ claude-templates/.ai/scripts/flashcard-sync | 98 ++++++ claude-templates/.ai/scripts/flashcard-to-anki.py | 232 +++++++++++++ .../.ai/scripts/tests/drill-deck-sync.bats | 38 --- .../.ai/scripts/tests/flashcard-sync.bats | 38 +++ .../.ai/scripts/tests/test_drill_deck_diff_ids.py | 109 ------ .../.ai/scripts/tests/test_drill_deck_stats.py | 379 --------------------- .../.ai/scripts/tests/test_drill_to_anki.py | 171 ---------- .../.ai/scripts/tests/test_flashcard_diff_ids.py | 109 ++++++ .../.ai/scripts/tests/test_flashcard_stats.py | 379 +++++++++++++++++++++ .../.ai/scripts/tests/test_flashcard_to_anki.py | 171 ++++++++++ claude-templates/.ai/workflows/INDEX.org | 6 +- .../.ai/workflows/drill-deck-review.org | 327 ------------------ .../.ai/workflows/flashcard-review.org | 327 ++++++++++++++++++ claude-templates/.ai/workflows/rename-artifact.org | 44 +++ scripts/rename-ai-artifact.sh | 129 +++++++ scripts/tests/rename-ai-artifact.bats | 119 +++++++ 42 files changed, 3904 insertions(+), 3564 deletions(-) delete mode 100755 .ai/scripts/drill-deck-diff-ids.py delete mode 100755 .ai/scripts/drill-deck-stats.py delete mode 100755 .ai/scripts/drill-deck-sync delete mode 100755 .ai/scripts/drill-to-anki.py create mode 100755 .ai/scripts/flashcard-diff-ids.py create mode 100755 .ai/scripts/flashcard-stats.py create mode 100755 .ai/scripts/flashcard-sync create mode 100755 .ai/scripts/flashcard-to-anki.py delete mode 100644 .ai/scripts/tests/drill-deck-sync.bats create mode 100644 .ai/scripts/tests/flashcard-sync.bats delete mode 100644 .ai/scripts/tests/test_drill_deck_diff_ids.py delete mode 100644 .ai/scripts/tests/test_drill_deck_stats.py delete mode 100644 .ai/scripts/tests/test_drill_to_anki.py create mode 100644 .ai/scripts/tests/test_flashcard_diff_ids.py create mode 100644 .ai/scripts/tests/test_flashcard_stats.py create mode 100644 .ai/scripts/tests/test_flashcard_to_anki.py delete mode 100644 .ai/workflows/drill-deck-review.org create mode 100644 .ai/workflows/flashcard-review.org create mode 100644 .ai/workflows/rename-artifact.org delete mode 100755 claude-templates/.ai/scripts/drill-deck-diff-ids.py delete mode 100755 claude-templates/.ai/scripts/drill-deck-stats.py delete mode 100755 claude-templates/.ai/scripts/drill-deck-sync delete mode 100755 claude-templates/.ai/scripts/drill-to-anki.py create mode 100755 claude-templates/.ai/scripts/flashcard-diff-ids.py create mode 100755 claude-templates/.ai/scripts/flashcard-stats.py create mode 100755 claude-templates/.ai/scripts/flashcard-sync create mode 100755 claude-templates/.ai/scripts/flashcard-to-anki.py delete mode 100644 claude-templates/.ai/scripts/tests/drill-deck-sync.bats create mode 100644 claude-templates/.ai/scripts/tests/flashcard-sync.bats delete mode 100644 claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py delete mode 100644 claude-templates/.ai/scripts/tests/test_drill_deck_stats.py delete mode 100644 claude-templates/.ai/scripts/tests/test_drill_to_anki.py create mode 100644 claude-templates/.ai/scripts/tests/test_flashcard_diff_ids.py create mode 100644 claude-templates/.ai/scripts/tests/test_flashcard_stats.py create mode 100644 claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py delete mode 100644 claude-templates/.ai/workflows/drill-deck-review.org create mode 100644 claude-templates/.ai/workflows/flashcard-review.org create mode 100644 claude-templates/.ai/workflows/rename-artifact.org create mode 100755 scripts/rename-ai-artifact.sh create mode 100644 scripts/tests/rename-ai-artifact.bats diff --git a/.ai/scripts/drill-deck-diff-ids.py b/.ai/scripts/drill-deck-diff-ids.py deleted file mode 100755 index bd2c4cc..0000000 --- a/.ai/scripts/drill-deck-diff-ids.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -"""SRS-state preservation check between two versions of an org-drill deck. - -Extracts every :ID: from each version and reports IDs that disappeared -or appeared. Disappeared IDs lose org-drill SRS state (review history, -ease, intervals) and are the worst-case bug from a deck rewrite. Appeared -IDs are usually fine (new cards added on purpose) but worth surfacing. - -Exits 0 when clean, 1 when any IDs disappeared or appeared. - -Usage: - drill-deck-diff-ids.py -""" -from __future__ import annotations - -import re -import sys -from pathlib import Path - -CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") -ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$") - - -def card_id_map(path: Path) -> dict[str, str]: - """Return {id -> heading} for every :drill: card in path.""" - result: dict[str, str] = {} - lines = path.read_text(encoding="utf-8").splitlines() - i = 0 - while i < len(lines): - m = CARD_RE.match(lines[i]) - if m: - heading = m.group(1).strip() - i += 1 - while i < len(lines): - line = lines[i] - if line.startswith("* ") or CARD_RE.match(line): - break - mid = ID_RE.match(line) - if mid: - result[mid.group(1)] = heading - break - i += 1 - continue - i += 1 - return result - - -def main() -> int: - if len(sys.argv) != 3: - print(f"usage: {sys.argv[0]} ", file=sys.stderr) - return 2 - - before_path = Path(sys.argv[1]).expanduser().resolve() - after_path = Path(sys.argv[2]).expanduser().resolve() - - for p in (before_path, after_path): - if not p.is_file(): - print(f"error: {p} not found", file=sys.stderr) - return 2 - - before = card_id_map(before_path) - after = card_id_map(after_path) - - before_ids = set(before) - after_ids = set(after) - - preserved = before_ids & after_ids - disappeared = before_ids - after_ids - appeared = after_ids - before_ids - - print(f"drill-deck-diff-ids: {before_path.name} → {after_path.name}") - print() - print(f"IDs in BEFORE: {len(before_ids)}") - print(f"IDs in AFTER: {len(after_ids)}") - print(f"Preserved: {len(preserved)}") - print(f"Disappeared: {len(disappeared)}") - print(f"Appeared: {len(appeared)}") - print() - - warnings = 0 - if disappeared: - warnings += 1 - print(f"WARN: {len(disappeared)} card IDs disappeared (SRS state lost)") - for cid in sorted(disappeared): - print(f" - {cid} (was: {before[cid]!r})") - if appeared: - warnings += 1 - print(f"NOTE: {len(appeared)} new card IDs appeared") - for cid in sorted(appeared): - print(f" - {cid} (now: {after[cid]!r})") - - if warnings == 0: - print("clean — SRS state preserved") - return 0 - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.ai/scripts/drill-deck-stats.py b/.ai/scripts/drill-deck-stats.py deleted file mode 100755 index 04c3468..0000000 --- a/.ai/scripts/drill-deck-stats.py +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 -"""Inventory + authoring-quality checks for an org-drill deck source file. - -Reports counts and flags two tiers of issue. - -Blocking WARNs (exit 1): -- PROPERTIES drawer count not matching card count -- Cards missing :ID: (risks SRS-state loss across rewrites) -- `*** Answer` sub-headers (should be 0 per drill-deck-review.org) -- Non-prompt headings (topic-as-heading not yet rewritten) -- #+TITLE missing, or carrying source-tool jargon ("org-drill") -- Answer leakage: a card whose question echoes most of its own answer - (Source: citation lines and created-date lines are excluded from the - overlap, and range/category cards that recall numbers are exempted) -- Duplicate / near-duplicate fronts (interference between confusable cards) - -Non-blocking NOTEs (exit unaffected): -- Overloaded backs (long answer — candidate to split into atomic cards) -- List-shaped backs (enumeration — candidate to split or use overlapping cloze) -- Binary yes/no prompts (low retrieval effort — candidate to reformulate) - -Exits 0 when no blocking warnings are present, 1 otherwise, 2 on bad usage. -Use as a gate before regenerating the Anki deck or running drill-deck-sync. - -The fuzzy checks (leakage, duplicate, overloaded) are tuned by the LEAKAGE_* -and BACK_WORD_LIMIT constants below; loosen them if a real deck trips false -positives. - -Usage: - drill-deck-stats.py -""" -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) -PLANNING_RE = re.compile(r"^\s*(SCHEDULED|DEADLINE|CLOSED):\s") -SOURCE_LINE_RE = re.compile(r"^\s*source:\s", re.IGNORECASE) -CREATED_LINE_RE = re.compile(r"^\s*:?created:?\s", re.IGNORECASE) -RANGE_RE = re.compile(r"\d[^\n]*[-–—]\s*\d") -THRESHOLD_RE = re.compile(r"[<>≤≥]\s*\d") -BULLET_RE = re.compile(r"^\s*([-+*]|\d+[.)])\s+") -BINARY_LEAD_RE = re.compile( - r"^\s*(is|are|was|were|does|do|did|can|could|should|would|will|has|have|had)\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", -}) - -# Function words ignored when comparing a question against its answer. -STOPWORDS = frozenset({ - "the", "a", "an", "is", "are", "was", "were", "of", "to", "in", "on", - "for", "and", "or", "with", "what", "who", "whom", "when", "where", "why", - "how", "which", "does", "do", "did", "tell", "me", "about", "their", "this", - "that", "it", "as", "at", "by", "be", "your", "you", "they", "them", -}) - -# Tuning knobs for the fuzzy checks. -LEAKAGE_RATIO = 0.8 # share of a question's content words echoed in its answer -LEAKAGE_MIN_WORDS = 3 # ignore very short questions, where overlap is noise -BACK_WORD_LIMIT = 60 # words on a card back before it's flagged as overloaded - - -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 content_words(text: str) -> set[str]: - """Lowercased alphanumeric tokens of length >= 3, minus stopwords.""" - return {w for w in re.findall(r"[a-z0-9]+", text.lower()) - if len(w) >= 3 and w not in STOPWORDS} - - -def leakage_ratio(heading: str, body: str) -> float: - """Fraction of the question's content words that reappear in the answer. - - A high ratio means the answer is largely restated in the question, so the - card can be answered by recognition rather than recall. Returns 0.0 for a - question with fewer than LEAKAGE_MIN_WORDS content words, where overlap is - just noise. - """ - hw = content_words(heading) - if len(hw) < LEAKAGE_MIN_WORDS: - return 0.0 - return len(hw & content_words(body)) / len(hw) - - -def prose_body(body: str) -> str: - """Body with Source: citation and created-date lines removed. - - Those lines are metadata, not the answer. A Source line's URL slug often - repeats the question's words, and a created date is bookkeeping — neither - should count toward answer-leakage overlap. - """ - return "\n".join( - ln for ln in body.splitlines() - if not SOURCE_LINE_RE.match(ln) and not CREATED_LINE_RE.match(ln) - ) - - -def has_distinct_numeric_recall(heading: str, body: str) -> bool: - """True if the answer carries numeric ranges/thresholds the question lacks. - - A range/category card ("What are the HbA1c ranges across normal, - prediabetes, and diabetes?") echoes its categories in the answer, but the - recalled content is the numbers, which the question doesn't give away — so - high word overlap isn't leakage. - """ - body_nums = bool(RANGE_RE.search(body) or THRESHOLD_RE.search(body)) - head_nums = bool(RANGE_RE.search(heading) or THRESHOLD_RE.search(heading)) - return body_nums and not head_nums - - -def is_leaky(heading: str, body: str) -> bool: - """True if a card leaks its answer, after excluding citation lines and - numeric-recall (range/category) cards.""" - prose = prose_body(body) - if leakage_ratio(heading, prose) < LEAKAGE_RATIO: - return False - return not has_distinct_numeric_recall(heading, prose) - - -def normalize_heading(heading: str) -> str: - """Collapse a heading to a comparison key (lowercase, alnum + single spaces).""" - return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", heading.lower())).strip() - - -def is_binary_prompt(heading: str) -> bool: - """True for yes/no or 'A or B' prompts, which need little retrieval effort.""" - if BINARY_LEAD_RE.match(heading): - return True - return bool(re.search(r"\bor\b", heading, re.IGNORECASE)) and heading.rstrip().endswith("?") - - -def back_word_count(body: str) -> int: - return len(body.split()) - - -def is_list_back(body: str) -> bool: - """True if the answer body is mostly an org list (an enumeration card).""" - lines = [ln for ln in body.splitlines() if ln.strip()] - if len(lines) < 2: - return False - bullets = sum(1 for ln in lines if BULLET_RE.match(ln)) - return bullets >= 2 and bullets * 2 >= len(lines) - - -def parse_cards(lines: list[str]) -> tuple[list[dict], int]: - """Parse :drill: cards from org lines. - - Returns (cards, prop_count). Each card is a dict with heading, has_id, - has_answer, and body (the answer text with PROPERTIES drawers, planning - lines, and `*** Answer` headers removed, approximating the rendered back). - """ - cards: list[dict] = [] - prop_count = 0 - i = 0 - n = len(lines) - while i < n: - m = CARD_RE.match(lines[i]) - if not m: - i += 1 - continue - heading = m.group(1).strip() - i += 1 - has_id = False - has_answer = False - in_drawer = False - body_lines: list[str] = [] - while i < n: - 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: - if ID_RE.match(line): - has_id = True - elif ANSWER_RE.match(line): - has_answer = True - elif PLANNING_RE.match(line): - pass - else: - body_lines.append(line) - i += 1 - cards.append({ - "heading": heading, - "has_id": has_id, - "has_answer": has_answer, - "body": "\n".join(body_lines).strip(), - }) - return cards, prop_count - - -def find_duplicate_fronts(cards: list[dict]) -> list[tuple[str, str]]: - """Return (first, dup) heading pairs that normalize to the same key.""" - seen: dict[str, str] = {} - dups: list[tuple[str, str]] = [] - for c in cards: - key = normalize_heading(c["heading"]) - if not key: - continue - if key in seen: - dups.append((seen[key], c["heading"])) - else: - seen[key] = c["heading"] - return dups - - -def main() -> int: - if len(sys.argv) != 2: - print(f"usage: {sys.argv[0]} ", 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, prop_count = parse_cards(lines) - - no_id = [c["heading"] for c in cards if not c["has_id"]] - not_prompt = [c["heading"] for c in cards if not is_prompt_form(c["heading"])] - answer_count = sum(1 for c in cards if c["has_answer"]) - leaky = [c["heading"] for c in cards if is_leaky(c["heading"], c["body"])] - dups = find_duplicate_fronts(cards) - overloaded = [c["heading"] for c in cards if back_word_count(c["body"]) > BACK_WORD_LIMIT] - listy = [c["heading"] for c in cards if is_list_back(c["body"])] - binary = [c["heading"] for c in cards if is_binary_prompt(c["heading"])] - - print(f"{path.name} — drill deck stats") - print() - print(f"Deck title: {title if title else '(no #+TITLE)'}") - 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})") - print(f"*** Answer sub-headers: {answer_count} ({'clean' if answer_count == 0 else 'workflow violation'})") - print(f"Cards missing :ID:: {len(no_id)}") - print(f"Cards with non-prompt heading: {len(not_prompt)}") - print(f"Cards with possible answer leakage: {len(leaky)}") - print(f"Duplicate / near-duplicate fronts: {len(dups)}") - print() - - warnings = 0 - - def emit_list(items: list[str]) -> None: - for h in items[:5]: - print(f" - {h}") - if len(items) > 5: - print(f" - ... and {len(items) - 5} more") - - def warn(msg: str, items: list[str] | None = None) -> None: - nonlocal warnings - warnings += 1 - print(f"WARN: {msg}") - if items: - emit_list(items) - - def note(msg: str, items: list[str] | None = None) -> None: - print(f"NOTE: {msg}") - if items: - emit_list(items) - - if title is None: - warn("no #+TITLE: line found; deck name will fall back to the file basename") - elif SOURCE_TOOL_RE.search(title): - warn(f"#+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: - warn(f"{answer_count} cards have *** Answer sub-headers (drop per drill-deck-review.org)") - if prop_count != len(cards): - warn(f"PROPERTIES count {prop_count} does not match card count {len(cards)}") - if no_id: - warn(f"{len(no_id)} cards missing :ID:; losing identity risks SRS-state loss across rewrites", no_id) - if not_prompt: - warn(f"{len(not_prompt)} cards have non-prompt headings (no '?' and no imperative-verb start); likely topic-as-heading not yet rewritten", not_prompt) - if leaky: - warn(f"{len(leaky)} cards may leak their answer (question echoes >= {int(LEAKAGE_RATIO * 100)}% of its own answer's key words); reformulate so the answer is recalled, not recognized", leaky) - if dups: - warn(f"{len(dups)} duplicate / near-duplicate fronts (interference between confusable cards); disambiguate or merge", - [f"{a} == {b}" for a, b in dups]) - - if overloaded: - note(f"{len(overloaded)} cards have a long answer (> {BACK_WORD_LIMIT} words); candidates to split into atomic cards", overloaded) - if listy: - note(f"{len(listy)} cards have a list-shaped answer; enumeration cards recall poorly — candidates to split or use overlapping cloze", listy) - if binary: - note(f"{len(binary)} cards are binary (yes/no or 'A or B'); low retrieval effort — candidates to reformulate open-ended", binary) - - if warnings == 0: - print("clean (with non-blocking notes above)" if (overloaded or listy or binary) else "clean") - return 0 - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.ai/scripts/drill-deck-sync b/.ai/scripts/drill-deck-sync deleted file mode 100755 index 8e51cdd..0000000 --- a/.ai/scripts/drill-deck-sync +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -# drill-deck-sync: stats check + regenerate Anki apkg + place at ~/sync/phone/anki/ -# -# Wraps drill-deck-stats.py + drill-to-anki.py (and optionally -# drill-deck-diff-ids.py) for the canonical "rewrote the deck, now ship -# it" step in the drill-deck-review workflow. -# -# Usage: -# drill-deck-sync -# drill-deck-sync --diff-against -# -# Exits non-zero when the stats check warns, when --diff-against shows -# any disappeared / appeared IDs, or when drill-to-anki.py fails. The -# Anki apkg is not written when any gate fails. - -set -euo pipefail - -usage() { - cat >&2 <<'EOF' -usage: drill-deck-sync [--diff-against ] -EOF - exit 2 -} - -if [[ $# -lt 1 ]]; then - usage -fi - -SOURCE="$1" -shift - -DIFF_AGAINST="" -while [[ $# -gt 0 ]]; do - case "$1" in - --diff-against) - [[ $# -ge 2 ]] || usage - DIFF_AGAINST="$2" - shift 2 - ;; - -h|--help) - usage - ;; - *) - echo "unknown arg: $1" >&2 - usage - ;; - esac -done - -if [[ ! -f "$SOURCE" ]]; then - echo "error: $SOURCE not found" >&2 - exit 2 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -STATS="$SCRIPT_DIR/drill-deck-stats.py" -DIFF_IDS="$SCRIPT_DIR/drill-deck-diff-ids.py" -TO_ANKI="$SCRIPT_DIR/drill-to-anki.py" - -for helper in "$STATS" "$DIFF_IDS" "$TO_ANKI"; do - if [[ ! -f "$helper" ]]; then - echo "error: helper $helper not found" >&2 - exit 2 - fi -done - -echo "=== drill-deck-sync: $SOURCE ===" -echo -echo "--- stats ---" -if ! python3 "$STATS" "$SOURCE"; then - echo - echo "stats check failed — fix warnings before sync, or call drill-to-anki.py directly to override" >&2 - exit 1 -fi -echo - -if [[ -n "$DIFF_AGAINST" ]]; then - if [[ ! -f "$DIFF_AGAINST" ]]; then - echo "error: $DIFF_AGAINST not found" >&2 - exit 2 - fi - echo "--- ID preservation ---" - if ! python3 "$DIFF_IDS" "$DIFF_AGAINST" "$SOURCE"; then - echo - echo "ID preservation check failed — SRS state may have been lost" >&2 - exit 1 - fi - echo -fi - -BASENAME="$(basename "$SOURCE" .org)" -OUTPUT="$HOME/sync/phone/anki/${BASENAME}.apkg" - -echo "--- regenerate apkg ---" -mkdir -p "$(dirname "$OUTPUT")" -"$TO_ANKI" "$SOURCE" --output "$OUTPUT" -echo -echo "deck synced to $OUTPUT" diff --git a/.ai/scripts/drill-to-anki.py b/.ai/scripts/drill-to-anki.py deleted file mode 100755 index 9fe954e..0000000 --- a/.ai/scripts/drill-to-anki.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "genanki>=0.13", -# ] -# /// -"""Convert an org-drill file into an Anki .apkg deck. - -Parses org-drill structure: - - Top-level "* Section" headings become tags on every card under them. - - Each "** Card name :drill:" entry becomes a card. Front = heading - text (sans :drill: tag). Back = entry body with newlines converted - to
. - -Deck name defaults to the input basename, case preserved. Deck and model -IDs are derived from the deck name via stable hash so re-importing the -same deck updates existing cards instead of duplicating them. - -Output defaults to ~/sync/phone/anki/.apkg. The .apkg is -a mobile-Anki artifact the phone picks up from its sync dir, so it lands -there rather than next to the org source. - -Usage: - drill-to-anki.py - drill-to-anki.py --deck "My Deck Name" - drill-to-anki.py --output /path/to/deck.apkg - -Requires genanki, which uv resolves automatically via the PEP 723 -script metadata above. No venv or system install needed. -""" -from __future__ import annotations - -import argparse -import hashlib -import re -import sys -from pathlib import Path - -import genanki - -# 32-bit integer space genanki accepts. Start above the conventional -# "user model" floor so collisions with hand-written decks stay -# unlikely. -ID_BASE = 1_500_000_000 -ID_RANGE = 500_000_000 - - -def stable_id(name: str, salt: str) -> int: - """Derive a deterministic 32-bit id from `name` and a `salt`. - - Same (name, salt) pair always returns the same id, so re-running - against the same source produces a stable deck/model id pair and - Anki imports update existing cards in place rather than duplicating. - """ - h = hashlib.sha256(f"{salt}:{name}".encode()).hexdigest() - return ID_BASE + (int(h[:8], 16) % ID_RANGE) - - -def make_model(deck_name: str) -> genanki.Model: - return genanki.Model( - stable_id(deck_name, "model"), - f"{deck_name} (Craig)", - fields=[{"name": "Front"}, {"name": "Back"}], - templates=[ - { - "name": "Card 1", - "qfmt": "{{Front}}", - "afmt": '{{FrontSide}}
{{Back}}', - } - ], - css=( - ".card { font-family: sans-serif; font-size: 18px; " - "color: #222; background: #fafafa; line-height: 1.45; }\n" - "hr#answer { margin: 14px 0; }\n" - ), - ) - - -def section_to_tag(title: str) -> str: - return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") - - -def escape_html(s: str) -> str: - return ( - s.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - ) - - -def strip_org_metadata(body_lines: list[str]) -> list[str]: - """Drop :PROPERTIES: drawers, planning lines, and created-date lines. - - Org-drill needs these in the source file (SRS state lives in the - PROPERTIES drawer; SCHEDULED carries the next-review date), but they - are noise on the back of an Anki card. A created/added date never - belongs on a card, so a stray "Created:" or ":CREATED:" body line is - dropped too. - """ - cleaned: list[str] = [] - in_drawer = False - planning_re = re.compile(r"^\s*(SCHEDULED|DEADLINE|CLOSED):\s") - created_re = re.compile(r"^\s*:?created:?\s", re.IGNORECASE) - drawer_start_re = re.compile(r"^\s*:PROPERTIES:\s*$") - drawer_end_re = re.compile(r"^\s*:END:\s*$") - for line in body_lines: - if in_drawer: - if drawer_end_re.match(line): - in_drawer = False - continue - if drawer_start_re.match(line): - in_drawer = True - continue - if planning_re.match(line) or created_re.match(line): - continue - cleaned.append(line) - return cleaned - - -def parse(org_text: str) -> list[tuple[str, str, str]]: - """Return [(front, back_html, tag), ...] for every :drill: card.""" - cards: list[tuple[str, str, str]] = [] - current_section: str | None = None - - section_re = re.compile(r"^\*\s+(.+?)\s*$") - card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") - - lines = org_text.splitlines() - i = 0 - while i < len(lines): - line = lines[i] - - sec = section_re.match(line) - if sec: - current_section = sec.group(1).strip() - i += 1 - continue - - card = card_re.match(line) - if card: - front = card.group(1).strip() - body_lines: list[str] = [] - i += 1 - while i < len(lines): - nxt = lines[i] - if nxt.startswith("* ") or card_re.match(nxt): - break - body_lines.append(nxt) - i += 1 - body_lines = strip_org_metadata(body_lines) - while body_lines and not body_lines[0].strip(): - body_lines.pop(0) - while body_lines and not body_lines[-1].strip(): - body_lines.pop() - back_html = "
".join(escape_html(ln) for ln in body_lines) - tag = section_to_tag(current_section) if current_section else "drill" - cards.append((front, back_html, tag)) - continue - - i += 1 - - return cards - - -def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck: - deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name) - model = make_model(deck_name) - for front, back, tag in cards: - note = genanki.Note( - model=model, - fields=[front, back], - tags=[tag], - guid=genanki.guid_for(front), - ) - deck.add_note(note) - return deck - - -def default_deck_name(input_path: Path) -> str: - return input_path.stem - - -def default_output_path(input_path: Path) -> Path: - anki_dir = Path.home() / "sync" / "phone" / "anki" - return anki_dir / f"{input_path.stem}.apkg" - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Convert an org-drill file into an Anki .apkg deck.", - ) - parser.add_argument( - "input", - type=Path, - help="Path to the org-drill source file.", - ) - parser.add_argument( - "--deck", - help="Deck name. Defaults to the input basename.", - ) - parser.add_argument( - "--output", - type=Path, - help="Output .apkg path. Defaults to " - "~/sync/phone/anki/.apkg.", - ) - args = parser.parse_args() - - input_path: Path = args.input.expanduser().resolve() - if not input_path.is_file(): - print(f"error: {input_path} not found", file=sys.stderr) - return 1 - - org_text = input_path.read_text(encoding="utf-8") - deck_name = args.deck or default_deck_name(input_path) - output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve() - output_path.parent.mkdir(parents=True, exist_ok=True) - - cards = parse(org_text) - if not cards: - print(f"error: no :drill: cards found in {input_path}", file=sys.stderr) - return 1 - - deck = build(cards, deck_name) - genanki.Package(deck).write_to_file(str(output_path)) - print(f"wrote {output_path} ({len(cards)} cards, deck '{deck_name}')") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.ai/scripts/flashcard-diff-ids.py b/.ai/scripts/flashcard-diff-ids.py new file mode 100755 index 0000000..152bb70 --- /dev/null +++ b/.ai/scripts/flashcard-diff-ids.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""SRS-state preservation check between two versions of an org-drill deck. + +Extracts every :ID: from each version and reports IDs that disappeared +or appeared. Disappeared IDs lose org-drill SRS state (review history, +ease, intervals) and are the worst-case bug from a deck rewrite. Appeared +IDs are usually fine (new cards added on purpose) but worth surfacing. + +Exits 0 when clean, 1 when any IDs disappeared or appeared. + +Usage: + flashcard-diff-ids.py +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") +ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$") + + +def card_id_map(path: Path) -> dict[str, str]: + """Return {id -> heading} for every :drill: card in path.""" + result: dict[str, str] = {} + lines = path.read_text(encoding="utf-8").splitlines() + i = 0 + while i < len(lines): + m = CARD_RE.match(lines[i]) + if m: + heading = m.group(1).strip() + i += 1 + while i < len(lines): + line = lines[i] + if line.startswith("* ") or CARD_RE.match(line): + break + mid = ID_RE.match(line) + if mid: + result[mid.group(1)] = heading + break + i += 1 + continue + i += 1 + return result + + +def main() -> int: + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + + before_path = Path(sys.argv[1]).expanduser().resolve() + after_path = Path(sys.argv[2]).expanduser().resolve() + + for p in (before_path, after_path): + if not p.is_file(): + print(f"error: {p} not found", file=sys.stderr) + return 2 + + before = card_id_map(before_path) + after = card_id_map(after_path) + + before_ids = set(before) + after_ids = set(after) + + preserved = before_ids & after_ids + disappeared = before_ids - after_ids + appeared = after_ids - before_ids + + print(f"flashcard-diff-ids: {before_path.name} → {after_path.name}") + print() + print(f"IDs in BEFORE: {len(before_ids)}") + print(f"IDs in AFTER: {len(after_ids)}") + print(f"Preserved: {len(preserved)}") + print(f"Disappeared: {len(disappeared)}") + print(f"Appeared: {len(appeared)}") + print() + + warnings = 0 + if disappeared: + warnings += 1 + print(f"WARN: {len(disappeared)} card IDs disappeared (SRS state lost)") + for cid in sorted(disappeared): + print(f" - {cid} (was: {before[cid]!r})") + if appeared: + warnings += 1 + print(f"NOTE: {len(appeared)} new card IDs appeared") + for cid in sorted(appeared): + print(f" - {cid} (now: {after[cid]!r})") + + if warnings == 0: + print("clean — SRS state preserved") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.ai/scripts/flashcard-stats.py b/.ai/scripts/flashcard-stats.py new file mode 100755 index 0000000..1fa5afb --- /dev/null +++ b/.ai/scripts/flashcard-stats.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Inventory + authoring-quality checks for an org-drill deck source file. + +Reports counts and flags two tiers of issue. + +Blocking WARNs (exit 1): +- PROPERTIES drawer count not matching card count +- Cards missing :ID: (risks SRS-state loss across rewrites) +- `*** Answer` sub-headers (should be 0 per flashcard-review.org) +- Non-prompt headings (topic-as-heading not yet rewritten) +- #+TITLE missing, or carrying source-tool jargon ("org-drill") +- Answer leakage: a card whose question echoes most of its own answer + (Source: citation lines and created-date lines are excluded from the + overlap, and range/category cards that recall numbers are exempted) +- Duplicate / near-duplicate fronts (interference between confusable cards) + +Non-blocking NOTEs (exit unaffected): +- Overloaded backs (long answer — candidate to split into atomic cards) +- List-shaped backs (enumeration — candidate to split or use overlapping cloze) +- Binary yes/no prompts (low retrieval effort — candidate to reformulate) + +Exits 0 when no blocking warnings are present, 1 otherwise, 2 on bad usage. +Use as a gate before regenerating the Anki deck or running flashcard-sync. + +The fuzzy checks (leakage, duplicate, overloaded) are tuned by the LEAKAGE_* +and BACK_WORD_LIMIT constants below; loosen them if a real deck trips false +positives. + +Usage: + flashcard-stats.py +""" +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) +PLANNING_RE = re.compile(r"^\s*(SCHEDULED|DEADLINE|CLOSED):\s") +SOURCE_LINE_RE = re.compile(r"^\s*source:\s", re.IGNORECASE) +CREATED_LINE_RE = re.compile(r"^\s*:?created:?\s", re.IGNORECASE) +RANGE_RE = re.compile(r"\d[^\n]*[-–—]\s*\d") +THRESHOLD_RE = re.compile(r"[<>≤≥]\s*\d") +BULLET_RE = re.compile(r"^\s*([-+*]|\d+[.)])\s+") +BINARY_LEAD_RE = re.compile( + r"^\s*(is|are|was|were|does|do|did|can|could|should|would|will|has|have|had)\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", +}) + +# Function words ignored when comparing a question against its answer. +STOPWORDS = frozenset({ + "the", "a", "an", "is", "are", "was", "were", "of", "to", "in", "on", + "for", "and", "or", "with", "what", "who", "whom", "when", "where", "why", + "how", "which", "does", "do", "did", "tell", "me", "about", "their", "this", + "that", "it", "as", "at", "by", "be", "your", "you", "they", "them", +}) + +# Tuning knobs for the fuzzy checks. +LEAKAGE_RATIO = 0.8 # share of a question's content words echoed in its answer +LEAKAGE_MIN_WORDS = 3 # ignore very short questions, where overlap is noise +BACK_WORD_LIMIT = 60 # words on a card back before it's flagged as overloaded + + +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 content_words(text: str) -> set[str]: + """Lowercased alphanumeric tokens of length >= 3, minus stopwords.""" + return {w for w in re.findall(r"[a-z0-9]+", text.lower()) + if len(w) >= 3 and w not in STOPWORDS} + + +def leakage_ratio(heading: str, body: str) -> float: + """Fraction of the question's content words that reappear in the answer. + + A high ratio means the answer is largely restated in the question, so the + card can be answered by recognition rather than recall. Returns 0.0 for a + question with fewer than LEAKAGE_MIN_WORDS content words, where overlap is + just noise. + """ + hw = content_words(heading) + if len(hw) < LEAKAGE_MIN_WORDS: + return 0.0 + return len(hw & content_words(body)) / len(hw) + + +def prose_body(body: str) -> str: + """Body with Source: citation and created-date lines removed. + + Those lines are metadata, not the answer. A Source line's URL slug often + repeats the question's words, and a created date is bookkeeping — neither + should count toward answer-leakage overlap. + """ + return "\n".join( + ln for ln in body.splitlines() + if not SOURCE_LINE_RE.match(ln) and not CREATED_LINE_RE.match(ln) + ) + + +def has_distinct_numeric_recall(heading: str, body: str) -> bool: + """True if the answer carries numeric ranges/thresholds the question lacks. + + A range/category card ("What are the HbA1c ranges across normal, + prediabetes, and diabetes?") echoes its categories in the answer, but the + recalled content is the numbers, which the question doesn't give away — so + high word overlap isn't leakage. + """ + body_nums = bool(RANGE_RE.search(body) or THRESHOLD_RE.search(body)) + head_nums = bool(RANGE_RE.search(heading) or THRESHOLD_RE.search(heading)) + return body_nums and not head_nums + + +def is_leaky(heading: str, body: str) -> bool: + """True if a card leaks its answer, after excluding citation lines and + numeric-recall (range/category) cards.""" + prose = prose_body(body) + if leakage_ratio(heading, prose) < LEAKAGE_RATIO: + return False + return not has_distinct_numeric_recall(heading, prose) + + +def normalize_heading(heading: str) -> str: + """Collapse a heading to a comparison key (lowercase, alnum + single spaces).""" + return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", heading.lower())).strip() + + +def is_binary_prompt(heading: str) -> bool: + """True for yes/no or 'A or B' prompts, which need little retrieval effort.""" + if BINARY_LEAD_RE.match(heading): + return True + return bool(re.search(r"\bor\b", heading, re.IGNORECASE)) and heading.rstrip().endswith("?") + + +def back_word_count(body: str) -> int: + return len(body.split()) + + +def is_list_back(body: str) -> bool: + """True if the answer body is mostly an org list (an enumeration card).""" + lines = [ln for ln in body.splitlines() if ln.strip()] + if len(lines) < 2: + return False + bullets = sum(1 for ln in lines if BULLET_RE.match(ln)) + return bullets >= 2 and bullets * 2 >= len(lines) + + +def parse_cards(lines: list[str]) -> tuple[list[dict], int]: + """Parse :drill: cards from org lines. + + Returns (cards, prop_count). Each card is a dict with heading, has_id, + has_answer, and body (the answer text with PROPERTIES drawers, planning + lines, and `*** Answer` headers removed, approximating the rendered back). + """ + cards: list[dict] = [] + prop_count = 0 + i = 0 + n = len(lines) + while i < n: + m = CARD_RE.match(lines[i]) + if not m: + i += 1 + continue + heading = m.group(1).strip() + i += 1 + has_id = False + has_answer = False + in_drawer = False + body_lines: list[str] = [] + while i < n: + 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: + if ID_RE.match(line): + has_id = True + elif ANSWER_RE.match(line): + has_answer = True + elif PLANNING_RE.match(line): + pass + else: + body_lines.append(line) + i += 1 + cards.append({ + "heading": heading, + "has_id": has_id, + "has_answer": has_answer, + "body": "\n".join(body_lines).strip(), + }) + return cards, prop_count + + +def find_duplicate_fronts(cards: list[dict]) -> list[tuple[str, str]]: + """Return (first, dup) heading pairs that normalize to the same key.""" + seen: dict[str, str] = {} + dups: list[tuple[str, str]] = [] + for c in cards: + key = normalize_heading(c["heading"]) + if not key: + continue + if key in seen: + dups.append((seen[key], c["heading"])) + else: + seen[key] = c["heading"] + return dups + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} ", 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, prop_count = parse_cards(lines) + + no_id = [c["heading"] for c in cards if not c["has_id"]] + not_prompt = [c["heading"] for c in cards if not is_prompt_form(c["heading"])] + answer_count = sum(1 for c in cards if c["has_answer"]) + leaky = [c["heading"] for c in cards if is_leaky(c["heading"], c["body"])] + dups = find_duplicate_fronts(cards) + overloaded = [c["heading"] for c in cards if back_word_count(c["body"]) > BACK_WORD_LIMIT] + listy = [c["heading"] for c in cards if is_list_back(c["body"])] + binary = [c["heading"] for c in cards if is_binary_prompt(c["heading"])] + + print(f"{path.name} — drill deck stats") + print() + print(f"Deck title: {title if title else '(no #+TITLE)'}") + 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})") + print(f"*** Answer sub-headers: {answer_count} ({'clean' if answer_count == 0 else 'workflow violation'})") + print(f"Cards missing :ID:: {len(no_id)}") + print(f"Cards with non-prompt heading: {len(not_prompt)}") + print(f"Cards with possible answer leakage: {len(leaky)}") + print(f"Duplicate / near-duplicate fronts: {len(dups)}") + print() + + warnings = 0 + + def emit_list(items: list[str]) -> None: + for h in items[:5]: + print(f" - {h}") + if len(items) > 5: + print(f" - ... and {len(items) - 5} more") + + def warn(msg: str, items: list[str] | None = None) -> None: + nonlocal warnings + warnings += 1 + print(f"WARN: {msg}") + if items: + emit_list(items) + + def note(msg: str, items: list[str] | None = None) -> None: + print(f"NOTE: {msg}") + if items: + emit_list(items) + + if title is None: + warn("no #+TITLE: line found; deck name will fall back to the file basename") + elif SOURCE_TOOL_RE.search(title): + warn(f"#+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: + warn(f"{answer_count} cards have *** Answer sub-headers (drop per flashcard-review.org)") + if prop_count != len(cards): + warn(f"PROPERTIES count {prop_count} does not match card count {len(cards)}") + if no_id: + warn(f"{len(no_id)} cards missing :ID:; losing identity risks SRS-state loss across rewrites", no_id) + if not_prompt: + warn(f"{len(not_prompt)} cards have non-prompt headings (no '?' and no imperative-verb start); likely topic-as-heading not yet rewritten", not_prompt) + if leaky: + warn(f"{len(leaky)} cards may leak their answer (question echoes >= {int(LEAKAGE_RATIO * 100)}% of its own answer's key words); reformulate so the answer is recalled, not recognized", leaky) + if dups: + warn(f"{len(dups)} duplicate / near-duplicate fronts (interference between confusable cards); disambiguate or merge", + [f"{a} == {b}" for a, b in dups]) + + if overloaded: + note(f"{len(overloaded)} cards have a long answer (> {BACK_WORD_LIMIT} words); candidates to split into atomic cards", overloaded) + if listy: + note(f"{len(listy)} cards have a list-shaped answer; enumeration cards recall poorly — candidates to split or use overlapping cloze", listy) + if binary: + note(f"{len(binary)} cards are binary (yes/no or 'A or B'); low retrieval effort — candidates to reformulate open-ended", binary) + + if warnings == 0: + print("clean (with non-blocking notes above)" if (overloaded or listy or binary) else "clean") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.ai/scripts/flashcard-sync b/.ai/scripts/flashcard-sync new file mode 100755 index 0000000..f5ba7fb --- /dev/null +++ b/.ai/scripts/flashcard-sync @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# flashcard-sync: stats check + regenerate Anki apkg + place at ~/sync/phone/anki/ +# +# Wraps flashcard-stats.py + flashcard-to-anki.py (and optionally +# flashcard-diff-ids.py) for the canonical "rewrote the deck, now ship +# it" step in the flashcard-review workflow. +# +# Usage: +# flashcard-sync +# flashcard-sync --diff-against +# +# Exits non-zero when the stats check warns, when --diff-against shows +# any disappeared / appeared IDs, or when flashcard-to-anki.py fails. The +# Anki apkg is not written when any gate fails. + +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +usage: flashcard-sync [--diff-against ] +EOF + exit 2 +} + +if [[ $# -lt 1 ]]; then + usage +fi + +SOURCE="$1" +shift + +DIFF_AGAINST="" +while [[ $# -gt 0 ]]; do + case "$1" in + --diff-against) + [[ $# -ge 2 ]] || usage + DIFF_AGAINST="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + echo "unknown arg: $1" >&2 + usage + ;; + esac +done + +if [[ ! -f "$SOURCE" ]]; then + echo "error: $SOURCE not found" >&2 + exit 2 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATS="$SCRIPT_DIR/flashcard-stats.py" +DIFF_IDS="$SCRIPT_DIR/flashcard-diff-ids.py" +TO_ANKI="$SCRIPT_DIR/flashcard-to-anki.py" + +for helper in "$STATS" "$DIFF_IDS" "$TO_ANKI"; do + if [[ ! -f "$helper" ]]; then + echo "error: helper $helper not found" >&2 + exit 2 + fi +done + +echo "=== flashcard-sync: $SOURCE ===" +echo +echo "--- stats ---" +if ! python3 "$STATS" "$SOURCE"; then + echo + echo "stats check failed — fix warnings before sync, or call flashcard-to-anki.py directly to override" >&2 + exit 1 +fi +echo + +if [[ -n "$DIFF_AGAINST" ]]; then + if [[ ! -f "$DIFF_AGAINST" ]]; then + echo "error: $DIFF_AGAINST not found" >&2 + exit 2 + fi + echo "--- ID preservation ---" + if ! python3 "$DIFF_IDS" "$DIFF_AGAINST" "$SOURCE"; then + echo + echo "ID preservation check failed — SRS state may have been lost" >&2 + exit 1 + fi + echo +fi + +BASENAME="$(basename "$SOURCE" .org)" +OUTPUT="$HOME/sync/phone/anki/${BASENAME}.apkg" + +echo "--- regenerate apkg ---" +mkdir -p "$(dirname "$OUTPUT")" +"$TO_ANKI" "$SOURCE" --output "$OUTPUT" +echo +echo "deck synced to $OUTPUT" diff --git a/.ai/scripts/flashcard-to-anki.py b/.ai/scripts/flashcard-to-anki.py new file mode 100755 index 0000000..7227683 --- /dev/null +++ b/.ai/scripts/flashcard-to-anki.py @@ -0,0 +1,232 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "genanki>=0.13", +# ] +# /// +"""Convert an org-drill file into an Anki .apkg deck. + +Parses org-drill structure: + - Top-level "* Section" headings become tags on every card under them. + - Each "** Card name :drill:" entry becomes a card. Front = heading + text (sans :drill: tag). Back = entry body with newlines converted + to
. + +Deck name defaults to the input basename, case preserved. Deck and model +IDs are derived from the deck name via stable hash so re-importing the +same deck updates existing cards instead of duplicating them. + +Output defaults to ~/sync/phone/anki/.apkg. The .apkg is +a mobile-Anki artifact the phone picks up from its sync dir, so it lands +there rather than next to the org source. + +Usage: + flashcard-to-anki.py + flashcard-to-anki.py --deck "My Deck Name" + flashcard-to-anki.py --output /path/to/deck.apkg + +Requires genanki, which uv resolves automatically via the PEP 723 +script metadata above. No venv or system install needed. +""" +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +from pathlib import Path + +import genanki + +# 32-bit integer space genanki accepts. Start above the conventional +# "user model" floor so collisions with hand-written decks stay +# unlikely. +ID_BASE = 1_500_000_000 +ID_RANGE = 500_000_000 + + +def stable_id(name: str, salt: str) -> int: + """Derive a deterministic 32-bit id from `name` and a `salt`. + + Same (name, salt) pair always returns the same id, so re-running + against the same source produces a stable deck/model id pair and + Anki imports update existing cards in place rather than duplicating. + """ + h = hashlib.sha256(f"{salt}:{name}".encode()).hexdigest() + return ID_BASE + (int(h[:8], 16) % ID_RANGE) + + +def make_model(deck_name: str) -> genanki.Model: + return genanki.Model( + stable_id(deck_name, "model"), + f"{deck_name} (Craig)", + fields=[{"name": "Front"}, {"name": "Back"}], + templates=[ + { + "name": "Card 1", + "qfmt": "{{Front}}", + "afmt": '{{FrontSide}}
{{Back}}', + } + ], + css=( + ".card { font-family: sans-serif; font-size: 18px; " + "color: #222; background: #fafafa; line-height: 1.45; }\n" + "hr#answer { margin: 14px 0; }\n" + ), + ) + + +def section_to_tag(title: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") + + +def escape_html(s: str) -> str: + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +def strip_org_metadata(body_lines: list[str]) -> list[str]: + """Drop :PROPERTIES: drawers, planning lines, and created-date lines. + + Org-drill needs these in the source file (SRS state lives in the + PROPERTIES drawer; SCHEDULED carries the next-review date), but they + are noise on the back of an Anki card. A created/added date never + belongs on a card, so a stray "Created:" or ":CREATED:" body line is + dropped too. + """ + cleaned: list[str] = [] + in_drawer = False + planning_re = re.compile(r"^\s*(SCHEDULED|DEADLINE|CLOSED):\s") + created_re = re.compile(r"^\s*:?created:?\s", re.IGNORECASE) + drawer_start_re = re.compile(r"^\s*:PROPERTIES:\s*$") + drawer_end_re = re.compile(r"^\s*:END:\s*$") + for line in body_lines: + if in_drawer: + if drawer_end_re.match(line): + in_drawer = False + continue + if drawer_start_re.match(line): + in_drawer = True + continue + if planning_re.match(line) or created_re.match(line): + continue + cleaned.append(line) + return cleaned + + +def parse(org_text: str) -> list[tuple[str, str, str]]: + """Return [(front, back_html, tag), ...] for every :drill: card.""" + cards: list[tuple[str, str, str]] = [] + current_section: str | None = None + + section_re = re.compile(r"^\*\s+(.+?)\s*$") + card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") + + lines = org_text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + + sec = section_re.match(line) + if sec: + current_section = sec.group(1).strip() + i += 1 + continue + + card = card_re.match(line) + if card: + front = card.group(1).strip() + body_lines: list[str] = [] + i += 1 + while i < len(lines): + nxt = lines[i] + if nxt.startswith("* ") or card_re.match(nxt): + break + body_lines.append(nxt) + i += 1 + body_lines = strip_org_metadata(body_lines) + while body_lines and not body_lines[0].strip(): + body_lines.pop(0) + while body_lines and not body_lines[-1].strip(): + body_lines.pop() + back_html = "
".join(escape_html(ln) for ln in body_lines) + tag = section_to_tag(current_section) if current_section else "drill" + cards.append((front, back_html, tag)) + continue + + i += 1 + + return cards + + +def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck: + deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name) + model = make_model(deck_name) + for front, back, tag in cards: + note = genanki.Note( + model=model, + fields=[front, back], + tags=[tag], + guid=genanki.guid_for(front), + ) + deck.add_note(note) + return deck + + +def default_deck_name(input_path: Path) -> str: + return input_path.stem + + +def default_output_path(input_path: Path) -> Path: + anki_dir = Path.home() / "sync" / "phone" / "anki" + return anki_dir / f"{input_path.stem}.apkg" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Convert an org-drill file into an Anki .apkg deck.", + ) + parser.add_argument( + "input", + type=Path, + help="Path to the org-drill source file.", + ) + parser.add_argument( + "--deck", + help="Deck name. Defaults to the input basename.", + ) + parser.add_argument( + "--output", + type=Path, + help="Output .apkg path. Defaults to " + "~/sync/phone/anki/.apkg.", + ) + args = parser.parse_args() + + input_path: Path = args.input.expanduser().resolve() + if not input_path.is_file(): + print(f"error: {input_path} not found", file=sys.stderr) + return 1 + + org_text = input_path.read_text(encoding="utf-8") + deck_name = args.deck or default_deck_name(input_path) + output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + cards = parse(org_text) + if not cards: + print(f"error: no :drill: cards found in {input_path}", file=sys.stderr) + return 1 + + deck = build(cards, deck_name) + genanki.Package(deck).write_to_file(str(output_path)) + print(f"wrote {output_path} ({len(cards)} cards, deck '{deck_name}')") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.ai/scripts/tests/drill-deck-sync.bats b/.ai/scripts/tests/drill-deck-sync.bats deleted file mode 100644 index e141cab..0000000 --- a/.ai/scripts/tests/drill-deck-sync.bats +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bats -# Tests for the drill-deck-sync wrapper: argument handling + the stats gate. -# The clean end-to-end path runs drill-to-anki.py (uv-resolved genanki) and is -# not exercised here; these cover the guard paths that stop before that step. - -setup() { - SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" - SYNC="$SCRIPT_DIR/drill-deck-sync" - TMP="$(mktemp -d)" -} - -teardown() { - rm -rf "$TMP" -} - -@test "drill-deck-sync: no args exits 2" { - run "$SYNC" - [ "$status" -eq 2 ] -} - -@test "drill-deck-sync: missing source file exits 2" { - run "$SYNC" "$TMP/nope.org" - [ "$status" -eq 2 ] -} - -@test "drill-deck-sync: stats gate failure exits 1 and writes no apkg" { - cat > "$TMP/dirty.org" <<'EOF' -#+TITLE: DeepSat Org-Drill Flashcards - -* Section -** DeepSat :drill: -*** Answer -A satellite company. -EOF - run "$SYNC" "$TMP/dirty.org" - [ "$status" -eq 1 ] - [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] -} diff --git a/.ai/scripts/tests/flashcard-sync.bats b/.ai/scripts/tests/flashcard-sync.bats new file mode 100644 index 0000000..608a280 --- /dev/null +++ b/.ai/scripts/tests/flashcard-sync.bats @@ -0,0 +1,38 @@ +#!/usr/bin/env bats +# Tests for the flashcard-sync wrapper: argument handling + the stats gate. +# The clean end-to-end path runs flashcard-to-anki.py (uv-resolved genanki) and is +# not exercised here; these cover the guard paths that stop before that step. + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + SYNC="$SCRIPT_DIR/flashcard-sync" + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +@test "flashcard-sync: no args exits 2" { + run "$SYNC" + [ "$status" -eq 2 ] +} + +@test "flashcard-sync: missing source file exits 2" { + run "$SYNC" "$TMP/nope.org" + [ "$status" -eq 2 ] +} + +@test "flashcard-sync: stats gate failure exits 1 and writes no apkg" { + cat > "$TMP/dirty.org" <<'EOF' +#+TITLE: DeepSat Org-Drill Flashcards + +* Section +** DeepSat :drill: +*** Answer +A satellite company. +EOF + run "$SYNC" "$TMP/dirty.org" + [ "$status" -eq 1 ] + [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] +} diff --git a/.ai/scripts/tests/test_drill_deck_diff_ids.py b/.ai/scripts/tests/test_drill_deck_diff_ids.py deleted file mode 100644 index 15fb148..0000000 --- a/.ai/scripts/tests/test_drill_deck_diff_ids.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Tests for drill-deck-diff-ids.py: :ID: extraction + SRS-state diff CLI. - -Plain python3 script (no third-party deps), so card_id_map imports directly; -the disappeared/appeared reporting is exercised through the CLI. -""" -from __future__ import annotations - -import importlib.util -import subprocess -import sys -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-diff-ids.py" - - -@pytest.fixture(scope="module") -def diff_ids(): - spec = importlib.util.spec_from_file_location("drill_deck_diff_ids", SCRIPT) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -DECK_A = """* Section -** What is DeepSat? :drill: -:PROPERTIES: -:ID: id-1 -:END: -Body. -** Who founded it? :drill: -:PROPERTIES: -:ID: id-2 -:END: -Body. -""" - -# id-2 dropped, id-3 added relative to DECK_A -DECK_B = """* Section -** What is DeepSat? :drill: -:PROPERTIES: -:ID: id-1 -:END: -Body. -** When was it founded? :drill: -:PROPERTIES: -:ID: id-3 -:END: -Body. -""" - - -def test_card_id_map_extracts_id_to_heading(diff_ids, tmp_path): - f = tmp_path / "a.org" - f.write_text(DECK_A) - m = diff_ids.card_id_map(f) - assert set(m) == {"id-1", "id-2"} - assert m["id-1"] == "What is DeepSat?" - - -def _run(before, after): - return subprocess.run( - [sys.executable, str(SCRIPT), str(before), str(after)], - capture_output=True, text=True, - ) - - -def test_cli_identical_decks_exit_zero(tmp_path): - a = tmp_path / "a.org" - a.write_text(DECK_A) - b = tmp_path / "b.org" - b.write_text(DECK_A) - r = _run(a, b) - assert r.returncode == 0 - assert "preserved" in r.stdout.lower() - - -def test_cli_dropped_id_warns_and_exits_one(tmp_path): - a = tmp_path / "a.org" - a.write_text(DECK_A) - b = tmp_path / "b.org" - b.write_text(DECK_B) - r = _run(a, b) - assert r.returncode == 1 - assert "disappeared" in r.stdout.lower() - assert "id-2" in r.stdout - - -DECK_ONE = """* Section -** What is DeepSat? :drill: -:PROPERTIES: -:ID: id-1 -:END: -Body. -""" - - -def test_cli_appeared_only_notes_new_ids_and_exits_one(tmp_path): - # before has id-1; after adds id-2 and drops nothing. - before = tmp_path / "before.org" - before.write_text(DECK_ONE) - after = tmp_path / "after.org" - after.write_text(DECK_A) - r = _run(before, after) - assert r.returncode == 1 - assert "appeared" in r.stdout.lower() - assert "id-2" in r.stdout diff --git a/.ai/scripts/tests/test_drill_deck_stats.py b/.ai/scripts/tests/test_drill_deck_stats.py deleted file mode 100644 index d60084d..0000000 --- a/.ai/scripts/tests/test_drill_deck_stats.py +++ /dev/null @@ -1,379 +0,0 @@ -"""Tests for drill-deck-stats.py: prompt-form heuristic + CLI inventory/gate. - -Plain python3 script (no third-party deps), so the pure helper imports directly; -the inventory/gate behavior is exercised through the CLI. -""" -from __future__ import annotations - -import importlib.util -import subprocess -import sys -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-stats.py" - - -@pytest.fixture(scope="module") -def stats(): - spec = importlib.util.spec_from_file_location("drill_deck_stats", SCRIPT) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -# --- is_prompt_form (pure) --- - -def test_is_prompt_form_question_mark(stats): - assert stats.is_prompt_form("What is DeepSat?") is True - - -def test_is_prompt_form_imperative_verb(stats): - assert stats.is_prompt_form("Spell out the orbital regimes") is True - - -def test_is_prompt_form_imperative_is_case_insensitive(stats): - assert stats.is_prompt_form("introduce yourself") is True - - -def test_is_prompt_form_topic_heading_is_not_a_prompt(stats): - assert stats.is_prompt_form("DeepSat") is False - - -def test_is_prompt_form_strips_trailing_punctuation_off_first_word(stats): - assert stats.is_prompt_form("List: the founders") is True - - -# --- CLI inventory + gate (integration) --- - -CLEAN_DECK = """#+TITLE: DeepSat Flashcards - -* Section -** What is DeepSat? :drill: -:PROPERTIES: -:ID: card-1 -:END: -A satellite company. -""" - -DIRTY_DECK = """#+TITLE: DeepSat Org-Drill Flashcards - -* Section -** DeepSat :drill: -*** Answer -A satellite company. -""" - - -def _run(path): - return subprocess.run( - [sys.executable, str(SCRIPT), str(path)], - capture_output=True, text=True, - ) - - -def test_cli_clean_deck_exits_zero(tmp_path): - f = tmp_path / "clean.org" - f.write_text(CLEAN_DECK) - r = _run(f) - assert r.returncode == 0 - assert "clean" in r.stdout - - -def test_cli_dirty_deck_warns_and_exits_one(tmp_path): - f = tmp_path / "dirty.org" - f.write_text(DIRTY_DECK) - r = _run(f) - assert r.returncode == 1 - assert "WARN" in r.stdout - assert "org-drill" in r.stdout.lower() # title-jargon audit fired - - -def test_cli_missing_file_exits_two(tmp_path): - r = _run(tmp_path / "nope.org") - assert r.returncode == 2 - - -NO_TITLE_DECK = """* Section -** What is DeepSat? :drill: -:PROPERTIES: -:ID: card-1 -:END: -A satellite company. -""" - -# Two cards, only one PROPERTIES drawer. -PROP_MISMATCH_DECK = """#+TITLE: DeepSat Flashcards - -* Section -** What is DeepSat? :drill: -A satellite company. -** Who founded it? :drill: -:PROPERTIES: -:ID: card-2 -:END: -The team. -""" - - -def test_cli_missing_title_warns_and_exits_one(tmp_path): - f = tmp_path / "notitle.org" - f.write_text(NO_TITLE_DECK) - r = _run(f) - assert r.returncode == 1 - assert "no #+TITLE" in r.stdout - - -def test_cli_properties_count_mismatch_warns_and_exits_one(tmp_path): - f = tmp_path / "mismatch.org" - f.write_text(PROP_MISMATCH_DECK) - r = _run(f) - assert r.returncode == 1 - assert "does not match card count" in r.stdout - - -# --- content_words / leakage_ratio (pure) --- - -def test_content_words_drops_stopwords_and_short_tokens(stats): - assert stats.content_words("What is the LEO regime?") == {"leo", "regime"} - - -def test_leakage_ratio_high_when_answer_restates_question(stats): - ratio = stats.leakage_ratio( - "primary orbital regimes satellites", - "the primary orbital regimes for satellites are listed", - ) - assert ratio == 1.0 - - -def test_leakage_ratio_zero_for_short_question(stats): - # "LEO" is the only content word, below LEAKAGE_MIN_WORDS, so overlap is noise. - assert stats.leakage_ratio("What is LEO?", "LEO means low earth orbit") == 0.0 - - -# --- normalize_heading (pure) --- - -def test_normalize_heading_lowercases_and_strips_punctuation(stats): - assert stats.normalize_heading(" What is L.E.O.? ") == "what is l e o" - - -def test_normalize_heading_collisions_match(stats): - assert stats.normalize_heading("What is LEO?") == stats.normalize_heading("what is leo") - - -# --- is_binary_prompt (pure) --- - -def test_is_binary_prompt_true_for_yes_no_lead(stats): - assert stats.is_binary_prompt("Is LEO below GEO?") is True - - -def test_is_binary_prompt_true_for_a_or_b(stats): - assert stats.is_binary_prompt("Is it LEO or GEO?") is True - - -def test_is_binary_prompt_false_for_open_question(stats): - assert stats.is_binary_prompt("What distinguishes LEO from GEO?") is False - - -# --- back_word_count / is_list_back (pure) --- - -def test_back_word_count(stats): - assert stats.back_word_count("one two three") == 3 - assert stats.back_word_count("") == 0 - - -def test_is_list_back_true_for_bulleted_body(stats): - assert stats.is_list_back("- LEO\n- MEO\n- GEO") is True - - -def test_is_list_back_false_for_prose(stats): - assert stats.is_list_back("Low Earth Orbit.\nThe closest regime.") is False - - -def test_is_list_back_false_for_single_bullet(stats): - assert stats.is_list_back("- only one bullet\nplain prose line") is False - - -# --- parse_cards (pure) --- - -def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stats): - text = ( - "* Sec\n" - "** Q one? :drill:\n" - ":PROPERTIES:\n:ID: id-1\n:END:\n" - "SCHEDULED: <2026-05-20 Wed>\n" - "*** Answer\n" - "the real answer\n" - ) - cards, prop_count = stats.parse_cards(text.splitlines()) - assert prop_count == 1 - assert len(cards) == 1 - c = cards[0] - assert c["heading"] == "Q one?" - assert c["has_id"] is True - assert c["has_answer"] is True - assert c["body"] == "the real answer" - - -def test_find_duplicate_fronts_matches_normalized_headings(stats): - cards = [ - {"heading": "What is LEO?"}, - {"heading": "what is leo?"}, - {"heading": "What is GEO?"}, - ] - dups = stats.find_duplicate_fronts(cards) - assert len(dups) == 1 - assert dups[0] == ("What is LEO?", "what is leo?") - - -# --- CLI: new blocking checks --- - -LEAKY_DECK = """#+TITLE: Test Flashcards - -* Section -** What are the primary orbital regimes for satellites? :drill: -:PROPERTIES: -:ID: c1 -:END: -The primary orbital regimes for satellites are listed here. -""" - -DUP_FRONT_DECK = """#+TITLE: Test Flashcards - -* Section -** What is LEO? :drill: -:PROPERTIES: -:ID: c1 -:END: -Low Earth Orbit. -** What is LEO? :drill: -:PROPERTIES: -:ID: c2 -:END: -Low Earth Orbit, restated. -""" - - -def test_cli_answer_leakage_warns_and_exits_one(tmp_path): - f = tmp_path / "leaky.org" - f.write_text(LEAKY_DECK) - r = _run(f) - assert r.returncode == 1 - assert "leak" in r.stdout.lower() - - -def test_cli_duplicate_front_warns_and_exits_one(tmp_path): - f = tmp_path / "dup.org" - f.write_text(DUP_FRONT_DECK) - r = _run(f) - assert r.returncode == 1 - assert "duplicate" in r.stdout.lower() - - -# --- CLI: non-blocking NOTEs keep exit 0 --- - -NOTES_DECK = """#+TITLE: Test Flashcards - -* Section -** Is LEO closer than GEO? :drill: -:PROPERTIES: -:ID: c1 -:END: -Yes, much closer. -** What orbital regimes exist? :drill: -:PROPERTIES: -:ID: c2 -:END: -- LEO -- MEO -- GEO -** Describe the platform elements in full :drill: -:PROPERTIES: -:ID: c3 -:END: -The platform carries power generation, propulsion, attitude control, thermal regulation, and radio hardware arranged around a central frame. Each element draws from shared resources and must survive launch loads, vacuum, and radiation. Engineers trade mass against capability when every kilogram raises cost, so redundancy is added only where a single failure would end the mission entirely and cheaper options cannot cover the same risk. -""" - - -def test_cli_non_blocking_notes_keep_exit_zero(tmp_path): - f = tmp_path / "notes.org" - f.write_text(NOTES_DECK) - r = _run(f) - assert r.returncode == 0 - assert "NOTE" in r.stdout - - -# --- leakage refinements: source-line strip + numeric carve-out --- - -def test_prose_body_strips_source_and_created_lines(stats): - body = "The real answer here.\nCreated: 2026-05-30\nSource: AHA — https://heart.org/x" - assert stats.prose_body(body) == "The real answer here." - - -def test_has_distinct_numeric_recall_true_for_range_card(stats): - assert stats.has_distinct_numeric_recall( - "What are the HbA1c ranges across normal, prediabetes, and diabetes?", - "Normal: <5.7%. Prediabetes: 5.7-6.4%. Diabetes: >=6.5%.", - ) is True - - -def test_has_distinct_numeric_recall_false_without_numbers(stats): - assert stats.has_distinct_numeric_recall("What is LEO?", "Low Earth Orbit.") is False - - -def test_is_leaky_false_when_overlap_is_only_in_the_source_line(stats): - heading = "What blood pressure constitutes a hypertensive crisis?" - body = ("A reading at or above 180/120.\n" - "Source: AHA — https://heart.org/high-blood-pressure/hypertensive-crisis") - assert stats.is_leaky(heading, body) is False - - -def test_is_leaky_false_for_numeric_range_card(stats): - heading = "What are the HbA1c ranges across normal, prediabetes, and diabetes?" - body = "HbA1c ranges. Normal: <5.7%. Prediabetes: 5.7-6.4%. Diabetes: >=6.5%." - assert stats.is_leaky(heading, body) is False - - -def test_is_leaky_true_for_genuine_restatement(stats): - heading = "primary orbital regimes satellites classification" - body = "The primary orbital regimes satellites classification scheme." - assert stats.is_leaky(heading, body) is True - - -SOURCE_LINE_DECK = """#+TITLE: Test Flashcards - -* Section -** What blood pressure constitutes a hypertensive crisis? :drill: -:PROPERTIES: -:ID: c1 -:END: -A reading at or above 180/120. - -Source: AHA — https://heart.org/high-blood-pressure/hypertensive-crisis-blood-pressure -""" - -RANGE_CARD_DECK = """#+TITLE: Test Flashcards - -* Section -** What are the HbA1c ranges across normal, prediabetes, and diabetes? :drill: -:PROPERTIES: -:ID: c1 -:END: -HbA1c ranges. Normal: <5.7%. Prediabetes: 5.7-6.4%. Diabetes: >=6.5%. -""" - - -def test_cli_source_line_overlap_is_not_flagged(tmp_path): - f = tmp_path / "source.org" - f.write_text(SOURCE_LINE_DECK) - r = _run(f) - assert r.returncode == 0 - - -def test_cli_numeric_range_card_is_not_flagged(tmp_path): - f = tmp_path / "range.org" - f.write_text(RANGE_CARD_DECK) - r = _run(f) - assert r.returncode == 0 diff --git a/.ai/scripts/tests/test_drill_to_anki.py b/.ai/scripts/tests/test_drill_to_anki.py deleted file mode 100644 index fc17817..0000000 --- a/.ai/scripts/tests/test_drill_to_anki.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Tests for drill-to-anki.py default-path and deck-name helpers. - -The script is a PEP 723 uv-run script that imports genanki, which uv resolves -at runtime but isn't installed in the test environment. The fixture stubs -genanki in sys.modules so the module loads; the pure helpers under test never -call into it. -""" -from __future__ import annotations - -import importlib.util -import sys -import types -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parents[1] / "drill-to-anki.py" - - -@pytest.fixture(scope="module") -def drill(): - # Only stub when genanki is genuinely absent, so a real install isn't shadowed. - sys.modules.setdefault("genanki", types.ModuleType("genanki")) - spec = importlib.util.spec_from_file_location("drill_to_anki", SCRIPT) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_default_output_path_targets_phone_anki_dir(drill): - """The .apkg is a phone artifact, so it defaults under sync/phone/anki/.""" - result = drill.default_output_path(Path("/home/x/projects/health/health-drill.org")) - assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg" - - -def test_default_deck_name_is_raw_basename(drill): - """Deck name is the input basename with case preserved; #+TITLE is ignored.""" - assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat" - - -def test_default_deck_name_keeps_hyphens(drill): - """A hyphenated basename is kept verbatim rather than title-cased.""" - assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill" - - -# --- section_to_tag (pure) --- - -def test_section_to_tag_slugifies_words(drill): - assert drill.section_to_tag("Orbital Regimes") == "orbital-regimes" - - -def test_section_to_tag_strips_leading_and_trailing_nonalnum(drill): - assert drill.section_to_tag(" People & Roles! ") == "people-roles" - - -def test_section_to_tag_empty_string(drill): - assert drill.section_to_tag("") == "" - - -# --- escape_html (pure) --- - -def test_escape_html_escapes_amp_lt_gt(drill): - assert drill.escape_html("a & b < c > d") == "a & b < c > d" - - -def test_escape_html_plain_text_unchanged(drill): - assert drill.escape_html("plain text") == "plain text" - - -def test_escape_html_escapes_amp_first_so_existing_entity_is_literal(drill): - # & is replaced before < / >, so a literal "<" becomes "&lt;", - # not silently treated as an already-escaped entity. - assert drill.escape_html("<") == "&lt;" - - -def test_escape_html_empty_string(drill): - assert drill.escape_html("") == "" - - -# --- stable_id (pure) --- - -def test_stable_id_is_deterministic(drill): - assert drill.stable_id("DeepSat", "deck") == drill.stable_id("DeepSat", "deck") - - -def test_stable_id_salt_changes_the_result(drill): - assert drill.stable_id("DeepSat", "deck") != drill.stable_id("DeepSat", "model") - - -def test_stable_id_stays_within_the_reserved_range(drill): - value = drill.stable_id("anything", "deck") - assert drill.ID_BASE <= value < drill.ID_BASE + drill.ID_RANGE - - -# --- strip_org_metadata (pure) --- - -def test_strip_org_metadata_drops_properties_drawer(drill): - body = [":PROPERTIES:", ":ID: x", ":END:", "real content"] - assert drill.strip_org_metadata(body) == ["real content"] - - -def test_strip_org_metadata_drops_planning_lines(drill): - body = ["SCHEDULED: <2026-05-30>", "DEADLINE: <2026-06-01>", - "CLOSED: [2026-05-29]", "body"] - assert drill.strip_org_metadata(body) == ["body"] - - -def test_strip_org_metadata_leaves_plain_body_unchanged(drill): - body = ["line one", "line two"] - assert drill.strip_org_metadata(body) == ["line one", "line two"] - - -def test_strip_org_metadata_empty_list(drill): - assert drill.strip_org_metadata([]) == [] - - -def test_strip_org_metadata_unclosed_drawer_swallows_the_rest(drill): - # An unterminated :PROPERTIES: drawer consumes everything after it. - body = [":PROPERTIES:", ":ID: x", "still in drawer"] - assert drill.strip_org_metadata(body) == [] - - -def test_strip_org_metadata_drops_created_date_line(drill): - # A created/added date never belongs on a card back. - assert drill.strip_org_metadata(["Created: 2026-05-30", "real answer"]) == ["real answer"] - - -# --- parse (pure, core parser) --- - -SECTIONED = """* Orbital Regimes -** What is LEO? :drill: -Low Earth Orbit. -** What is GEO? :drill: -Geostationary Earth Orbit. -""" - - -def test_parse_returns_front_back_tag_per_card(drill): - cards = drill.parse(SECTIONED) - assert len(cards) == 2 - assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes") - assert cards[1][0] == "What is GEO?" - - -def test_parse_card_without_a_section_gets_the_drill_tag(drill): - assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")] - - -def test_parse_strips_properties_drawer_from_back(drill): - text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n" - assert drill.parse(text) == [("Q?", "The answer.", "drill")] - - -def test_parse_trims_leading_and_trailing_blank_body_lines(drill): - cards = drill.parse("** Q? :drill:\n\n\nanswer\n\n\n") - assert cards[0][1] == "answer" - - -def test_parse_card_with_only_a_drawer_has_empty_back(drill): - text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n" - assert drill.parse(text) == [("Q?", "", "drill")] - - -def test_parse_joins_multiline_body_with_br(drill): - cards = drill.parse("** Q? :drill:\nline one\nline two\n") - assert cards[0][1] == "line one
line two" - - -def test_parse_no_drill_cards_returns_empty(drill): - assert drill.parse("* Section\nno drill cards here\n") == [] diff --git a/.ai/scripts/tests/test_flashcard_diff_ids.py b/.ai/scripts/tests/test_flashcard_diff_ids.py new file mode 100644 index 0000000..9554b48 --- /dev/null +++ b/.ai/scripts/tests/test_flashcard_diff_ids.py @@ -0,0 +1,109 @@ +"""Tests for flashcard-diff-ids.py: :ID: extraction + SRS-state diff CLI. + +Plain python3 script (no third-party deps), so card_id_map imports directly; +the disappeared/appeared reporting is exercised through the CLI. +""" +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-diff-ids.py" + + +@pytest.fixture(scope="module") +def diff_ids(): + spec = importlib.util.spec_from_file_location("flashcard_diff_ids", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +DECK_A = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +** Who founded it? :drill: +:PROPERTIES: +:ID: id-2 +:END: +Body. +""" + +# id-2 dropped, id-3 added relative to DECK_A +DECK_B = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +** When was it founded? :drill: +:PROPERTIES: +:ID: id-3 +:END: +Body. +""" + + +def test_card_id_map_extracts_id_to_heading(diff_ids, tmp_path): + f = tmp_path / "a.org" + f.write_text(DECK_A) + m = diff_ids.card_id_map(f) + assert set(m) == {"id-1", "id-2"} + assert m["id-1"] == "What is DeepSat?" + + +def _run(before, after): + return subprocess.run( + [sys.executable, str(SCRIPT), str(before), str(after)], + capture_output=True, text=True, + ) + + +def test_cli_identical_decks_exit_zero(tmp_path): + a = tmp_path / "a.org" + a.write_text(DECK_A) + b = tmp_path / "b.org" + b.write_text(DECK_A) + r = _run(a, b) + assert r.returncode == 0 + assert "preserved" in r.stdout.lower() + + +def test_cli_dropped_id_warns_and_exits_one(tmp_path): + a = tmp_path / "a.org" + a.write_text(DECK_A) + b = tmp_path / "b.org" + b.write_text(DECK_B) + r = _run(a, b) + assert r.returncode == 1 + assert "disappeared" in r.stdout.lower() + assert "id-2" in r.stdout + + +DECK_ONE = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +""" + + +def test_cli_appeared_only_notes_new_ids_and_exits_one(tmp_path): + # before has id-1; after adds id-2 and drops nothing. + before = tmp_path / "before.org" + before.write_text(DECK_ONE) + after = tmp_path / "after.org" + after.write_text(DECK_A) + r = _run(before, after) + assert r.returncode == 1 + assert "appeared" in r.stdout.lower() + assert "id-2" in r.stdout diff --git a/.ai/scripts/tests/test_flashcard_stats.py b/.ai/scripts/tests/test_flashcard_stats.py new file mode 100644 index 0000000..606f7c1 --- /dev/null +++ b/.ai/scripts/tests/test_flashcard_stats.py @@ -0,0 +1,379 @@ +"""Tests for flashcard-stats.py: prompt-form heuristic + CLI inventory/gate. + +Plain python3 script (no third-party deps), so the pure helper imports directly; +the inventory/gate behavior is exercised through the CLI. +""" +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-stats.py" + + +@pytest.fixture(scope="module") +def stats(): + spec = importlib.util.spec_from_file_location("flashcard_stats", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --- is_prompt_form (pure) --- + +def test_is_prompt_form_question_mark(stats): + assert stats.is_prompt_form("What is DeepSat?") is True + + +def test_is_prompt_form_imperative_verb(stats): + assert stats.is_prompt_form("Spell out the orbital regimes") is True + + +def test_is_prompt_form_imperative_is_case_insensitive(stats): + assert stats.is_prompt_form("introduce yourself") is True + + +def test_is_prompt_form_topic_heading_is_not_a_prompt(stats): + assert stats.is_prompt_form("DeepSat") is False + + +def test_is_prompt_form_strips_trailing_punctuation_off_first_word(stats): + assert stats.is_prompt_form("List: the founders") is True + + +# --- CLI inventory + gate (integration) --- + +CLEAN_DECK = """#+TITLE: DeepSat Flashcards + +* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: card-1 +:END: +A satellite company. +""" + +DIRTY_DECK = """#+TITLE: DeepSat Org-Drill Flashcards + +* Section +** DeepSat :drill: +*** Answer +A satellite company. +""" + + +def _run(path): + return subprocess.run( + [sys.executable, str(SCRIPT), str(path)], + capture_output=True, text=True, + ) + + +def test_cli_clean_deck_exits_zero(tmp_path): + f = tmp_path / "clean.org" + f.write_text(CLEAN_DECK) + r = _run(f) + assert r.returncode == 0 + assert "clean" in r.stdout + + +def test_cli_dirty_deck_warns_and_exits_one(tmp_path): + f = tmp_path / "dirty.org" + f.write_text(DIRTY_DECK) + r = _run(f) + assert r.returncode == 1 + assert "WARN" in r.stdout + assert "org-drill" in r.stdout.lower() # title-jargon audit fired + + +def test_cli_missing_file_exits_two(tmp_path): + r = _run(tmp_path / "nope.org") + assert r.returncode == 2 + + +NO_TITLE_DECK = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: card-1 +:END: +A satellite company. +""" + +# Two cards, only one PROPERTIES drawer. +PROP_MISMATCH_DECK = """#+TITLE: DeepSat Flashcards + +* Section +** What is DeepSat? :drill: +A satellite company. +** Who founded it? :drill: +:PROPERTIES: +:ID: card-2 +:END: +The team. +""" + + +def test_cli_missing_title_warns_and_exits_one(tmp_path): + f = tmp_path / "notitle.org" + f.write_text(NO_TITLE_DECK) + r = _run(f) + assert r.returncode == 1 + assert "no #+TITLE" in r.stdout + + +def test_cli_properties_count_mismatch_warns_and_exits_one(tmp_path): + f = tmp_path / "mismatch.org" + f.write_text(PROP_MISMATCH_DECK) + r = _run(f) + assert r.returncode == 1 + assert "does not match card count" in r.stdout + + +# --- content_words / leakage_ratio (pure) --- + +def test_content_words_drops_stopwords_and_short_tokens(stats): + assert stats.content_words("What is the LEO regime?") == {"leo", "regime"} + + +def test_leakage_ratio_high_when_answer_restates_question(stats): + ratio = stats.leakage_ratio( + "primary orbital regimes satellites", + "the primary orbital regimes for satellites are listed", + ) + assert ratio == 1.0 + + +def test_leakage_ratio_zero_for_short_question(stats): + # "LEO" is the only content word, below LEAKAGE_MIN_WORDS, so overlap is noise. + assert stats.leakage_ratio("What is LEO?", "LEO means low earth orbit") == 0.0 + + +# --- normalize_heading (pure) --- + +def test_normalize_heading_lowercases_and_strips_punctuation(stats): + assert stats.normalize_heading(" What is L.E.O.? ") == "what is l e o" + + +def test_normalize_heading_collisions_match(stats): + assert stats.normalize_heading("What is LEO?") == stats.normalize_heading("what is leo") + + +# --- is_binary_prompt (pure) --- + +def test_is_binary_prompt_true_for_yes_no_lead(stats): + assert stats.is_binary_prompt("Is LEO below GEO?") is True + + +def test_is_binary_prompt_true_for_a_or_b(stats): + assert stats.is_binary_prompt("Is it LEO or GEO?") is True + + +def test_is_binary_prompt_false_for_open_question(stats): + assert stats.is_binary_prompt("What distinguishes LEO from GEO?") is False + + +# --- back_word_count / is_list_back (pure) --- + +def test_back_word_count(stats): + assert stats.back_word_count("one two three") == 3 + assert stats.back_word_count("") == 0 + + +def test_is_list_back_true_for_bulleted_body(stats): + assert stats.is_list_back("- LEO\n- MEO\n- GEO") is True + + +def test_is_list_back_false_for_prose(stats): + assert stats.is_list_back("Low Earth Orbit.\nThe closest regime.") is False + + +def test_is_list_back_false_for_single_bullet(stats): + assert stats.is_list_back("- only one bullet\nplain prose line") is False + + +# --- parse_cards (pure) --- + +def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stats): + text = ( + "* Sec\n" + "** Q one? :drill:\n" + ":PROPERTIES:\n:ID: id-1\n:END:\n" + "SCHEDULED: <2026-05-20 Wed>\n" + "*** Answer\n" + "the real answer\n" + ) + cards, prop_count = stats.parse_cards(text.splitlines()) + assert prop_count == 1 + assert len(cards) == 1 + c = cards[0] + assert c["heading"] == "Q one?" + assert c["has_id"] is True + assert c["has_answer"] is True + assert c["body"] == "the real answer" + + +def test_find_duplicate_fronts_matches_normalized_headings(stats): + cards = [ + {"heading": "What is LEO?"}, + {"heading": "what is leo?"}, + {"heading": "What is GEO?"}, + ] + dups = stats.find_duplicate_fronts(cards) + assert len(dups) == 1 + assert dups[0] == ("What is LEO?", "what is leo?") + + +# --- CLI: new blocking checks --- + +LEAKY_DECK = """#+TITLE: Test Flashcards + +* Section +** What are the primary orbital regimes for satellites? :drill: +:PROPERTIES: +:ID: c1 +:END: +The primary orbital regimes for satellites are listed here. +""" + +DUP_FRONT_DECK = """#+TITLE: Test Flashcards + +* Section +** What is LEO? :drill: +:PROPERTIES: +:ID: c1 +:END: +Low Earth Orbit. +** What is LEO? :drill: +:PROPERTIES: +:ID: c2 +:END: +Low Earth Orbit, restated. +""" + + +def test_cli_answer_leakage_warns_and_exits_one(tmp_path): + f = tmp_path / "leaky.org" + f.write_text(LEAKY_DECK) + r = _run(f) + assert r.returncode == 1 + assert "leak" in r.stdout.lower() + + +def test_cli_duplicate_front_warns_and_exits_one(tmp_path): + f = tmp_path / "dup.org" + f.write_text(DUP_FRONT_DECK) + r = _run(f) + assert r.returncode == 1 + assert "duplicate" in r.stdout.lower() + + +# --- CLI: non-blocking NOTEs keep exit 0 --- + +NOTES_DECK = """#+TITLE: Test Flashcards + +* Section +** Is LEO closer than GEO? :drill: +:PROPERTIES: +:ID: c1 +:END: +Yes, much closer. +** What orbital regimes exist? :drill: +:PROPERTIES: +:ID: c2 +:END: +- LEO +- MEO +- GEO +** Describe the platform elements in full :drill: +:PROPERTIES: +:ID: c3 +:END: +The platform carries power generation, propulsion, attitude control, thermal regulation, and radio hardware arranged around a central frame. Each element draws from shared resources and must survive launch loads, vacuum, and radiation. Engineers trade mass against capability when every kilogram raises cost, so redundancy is added only where a single failure would end the mission entirely and cheaper options cannot cover the same risk. +""" + + +def test_cli_non_blocking_notes_keep_exit_zero(tmp_path): + f = tmp_path / "notes.org" + f.write_text(NOTES_DECK) + r = _run(f) + assert r.returncode == 0 + assert "NOTE" in r.stdout + + +# --- leakage refinements: source-line strip + numeric carve-out --- + +def test_prose_body_strips_source_and_created_lines(stats): + body = "The real answer here.\nCreated: 2026-05-30\nSource: AHA — https://heart.org/x" + assert stats.prose_body(body) == "The real answer here." + + +def test_has_distinct_numeric_recall_true_for_range_card(stats): + assert stats.has_distinct_numeric_recall( + "What are the HbA1c ranges across normal, prediabetes, and diabetes?", + "Normal: <5.7%. Prediabetes: 5.7-6.4%. Diabetes: >=6.5%.", + ) is True + + +def test_has_distinct_numeric_recall_false_without_numbers(stats): + assert stats.has_distinct_numeric_recall("What is LEO?", "Low Earth Orbit.") is False + + +def test_is_leaky_false_when_overlap_is_only_in_the_source_line(stats): + heading = "What blood pressure constitutes a hypertensive crisis?" + body = ("A reading at or above 180/120.\n" + "Source: AHA — https://heart.org/high-blood-pressure/hypertensive-crisis") + assert stats.is_leaky(heading, body) is False + + +def test_is_leaky_false_for_numeric_range_card(stats): + heading = "What are the HbA1c ranges across normal, prediabetes, and diabetes?" + body = "HbA1c ranges. Normal: <5.7%. Prediabetes: 5.7-6.4%. Diabetes: >=6.5%." + assert stats.is_leaky(heading, body) is False + + +def test_is_leaky_true_for_genuine_restatement(stats): + heading = "primary orbital regimes satellites classification" + body = "The primary orbital regimes satellites classification scheme." + assert stats.is_leaky(heading, body) is True + + +SOURCE_LINE_DECK = """#+TITLE: Test Flashcards + +* Section +** What blood pressure constitutes a hypertensive crisis? :drill: +:PROPERTIES: +:ID: c1 +:END: +A reading at or above 180/120. + +Source: AHA — https://heart.org/high-blood-pressure/hypertensive-crisis-blood-pressure +""" + +RANGE_CARD_DECK = """#+TITLE: Test Flashcards + +* Section +** What are the HbA1c ranges across normal, prediabetes, and diabetes? :drill: +:PROPERTIES: +:ID: c1 +:END: +HbA1c ranges. Normal: <5.7%. Prediabetes: 5.7-6.4%. Diabetes: >=6.5%. +""" + + +def test_cli_source_line_overlap_is_not_flagged(tmp_path): + f = tmp_path / "source.org" + f.write_text(SOURCE_LINE_DECK) + r = _run(f) + assert r.returncode == 0 + + +def test_cli_numeric_range_card_is_not_flagged(tmp_path): + f = tmp_path / "range.org" + f.write_text(RANGE_CARD_DECK) + r = _run(f) + assert r.returncode == 0 diff --git a/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py new file mode 100644 index 0000000..058b0cd --- /dev/null +++ b/.ai/scripts/tests/test_flashcard_to_anki.py @@ -0,0 +1,171 @@ +"""Tests for flashcard-to-anki.py default-path and deck-name helpers. + +The script is a PEP 723 uv-run script that imports genanki, which uv resolves +at runtime but isn't installed in the test environment. The fixture stubs +genanki in sys.modules so the module loads; the pure helpers under test never +call into it. +""" +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-to-anki.py" + + +@pytest.fixture(scope="module") +def drill(): + # Only stub when genanki is genuinely absent, so a real install isn't shadowed. + sys.modules.setdefault("genanki", types.ModuleType("genanki")) + spec = importlib.util.spec_from_file_location("flashcard_to_anki", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_default_output_path_targets_phone_anki_dir(drill): + """The .apkg is a phone artifact, so it defaults under sync/phone/anki/.""" + result = drill.default_output_path(Path("/home/x/projects/health/health-drill.org")) + assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg" + + +def test_default_deck_name_is_raw_basename(drill): + """Deck name is the input basename with case preserved; #+TITLE is ignored.""" + assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat" + + +def test_default_deck_name_keeps_hyphens(drill): + """A hyphenated basename is kept verbatim rather than title-cased.""" + assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill" + + +# --- section_to_tag (pure) --- + +def test_section_to_tag_slugifies_words(drill): + assert drill.section_to_tag("Orbital Regimes") == "orbital-regimes" + + +def test_section_to_tag_strips_leading_and_trailing_nonalnum(drill): + assert drill.section_to_tag(" People & Roles! ") == "people-roles" + + +def test_section_to_tag_empty_string(drill): + assert drill.section_to_tag("") == "" + + +# --- escape_html (pure) --- + +def test_escape_html_escapes_amp_lt_gt(drill): + assert drill.escape_html("a & b < c > d") == "a & b < c > d" + + +def test_escape_html_plain_text_unchanged(drill): + assert drill.escape_html("plain text") == "plain text" + + +def test_escape_html_escapes_amp_first_so_existing_entity_is_literal(drill): + # & is replaced before < / >, so a literal "<" becomes "&lt;", + # not silently treated as an already-escaped entity. + assert drill.escape_html("<") == "&lt;" + + +def test_escape_html_empty_string(drill): + assert drill.escape_html("") == "" + + +# --- stable_id (pure) --- + +def test_stable_id_is_deterministic(drill): + assert drill.stable_id("DeepSat", "deck") == drill.stable_id("DeepSat", "deck") + + +def test_stable_id_salt_changes_the_result(drill): + assert drill.stable_id("DeepSat", "deck") != drill.stable_id("DeepSat", "model") + + +def test_stable_id_stays_within_the_reserved_range(drill): + value = drill.stable_id("anything", "deck") + assert drill.ID_BASE <= value < drill.ID_BASE + drill.ID_RANGE + + +# --- strip_org_metadata (pure) --- + +def test_strip_org_metadata_drops_properties_drawer(drill): + body = [":PROPERTIES:", ":ID: x", ":END:", "real content"] + assert drill.strip_org_metadata(body) == ["real content"] + + +def test_strip_org_metadata_drops_planning_lines(drill): + body = ["SCHEDULED: <2026-05-30>", "DEADLINE: <2026-06-01>", + "CLOSED: [2026-05-29]", "body"] + assert drill.strip_org_metadata(body) == ["body"] + + +def test_strip_org_metadata_leaves_plain_body_unchanged(drill): + body = ["line one", "line two"] + assert drill.strip_org_metadata(body) == ["line one", "line two"] + + +def test_strip_org_metadata_empty_list(drill): + assert drill.strip_org_metadata([]) == [] + + +def test_strip_org_metadata_unclosed_drawer_swallows_the_rest(drill): + # An unterminated :PROPERTIES: drawer consumes everything after it. + body = [":PROPERTIES:", ":ID: x", "still in drawer"] + assert drill.strip_org_metadata(body) == [] + + +def test_strip_org_metadata_drops_created_date_line(drill): + # A created/added date never belongs on a card back. + assert drill.strip_org_metadata(["Created: 2026-05-30", "real answer"]) == ["real answer"] + + +# --- parse (pure, core parser) --- + +SECTIONED = """* Orbital Regimes +** What is LEO? :drill: +Low Earth Orbit. +** What is GEO? :drill: +Geostationary Earth Orbit. +""" + + +def test_parse_returns_front_back_tag_per_card(drill): + cards = drill.parse(SECTIONED) + assert len(cards) == 2 + assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes") + assert cards[1][0] == "What is GEO?" + + +def test_parse_card_without_a_section_gets_the_drill_tag(drill): + assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")] + + +def test_parse_strips_properties_drawer_from_back(drill): + text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n" + assert drill.parse(text) == [("Q?", "The answer.", "drill")] + + +def test_parse_trims_leading_and_trailing_blank_body_lines(drill): + cards = drill.parse("** Q? :drill:\n\n\nanswer\n\n\n") + assert cards[0][1] == "answer" + + +def test_parse_card_with_only_a_drawer_has_empty_back(drill): + text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n" + assert drill.parse(text) == [("Q?", "", "drill")] + + +def test_parse_joins_multiline_body_with_br(drill): + cards = drill.parse("** Q? :drill:\nline one\nline two\n") + assert cards[0][1] == "line one
line two" + + +def test_parse_no_drill_cards_returns_empty(drill): + assert drill.parse("* Section\nno drill cards here\n") == [] diff --git a/.ai/workflows/INDEX.org b/.ai/workflows/INDEX.org index c8554d4..157a4e7 100644 --- a/.ai/workflows/INDEX.org +++ b/.ai/workflows/INDEX.org @@ -86,14 +86,16 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e - Triggers: "page me on signal", "signal me when X is done", "send a signal note about X" - =cross-project-broadcast.org= — fan out a single message to every AI project's inbox via the discovery helper =cross-project-broadcast.py= + the existing =inbox-send.py=. Use sparingly for capability announcements and shared rule changes; not for project-specific handoffs. - Triggers: "broadcast this to every project", "notify every project about X", "fan out this announcement", "let every project know X is available" -- =drill-deck-review.org= — review an org-drill flashcard file, restructure cards to question-form headings (no answer hints), audit content accuracy against project source-of-truth via subagent, rewrite source preserving SRS state, regenerate the Anki =.apkg= to =~/sync/phone/anki/=. Person cards use "Who is X? Tell me about their Y."; talking-points cards stay as-is. Script behavior: =drill-to-anki.py= strips =:PROPERTIES:= drawers + =SCHEDULED:= / =DEADLINE:= planning lines from Anki output. - - Triggers: "review the drill deck", "update the drill deck", "refresh the Anki cards", "let's run the drill-deck-review workflow" +- =flashcard-review.org= — review an org-drill flashcard file, restructure cards to question-form headings (no answer hints), audit content accuracy against project source-of-truth via subagent, rewrite source preserving SRS state, regenerate the Anki =.apkg= to =~/sync/phone/anki/=. Person cards use "Who is X? Tell me about their Y."; talking-points cards stay as-is. Script behavior: =flashcard-to-anki.py= strips =:PROPERTIES:= drawers + =SCHEDULED:= / =DEADLINE:= planning lines from Anki output. + - Triggers: "review the flashcards", "update the flashcards", "review the drill deck", "update the drill deck", "refresh the Anki cards", "let's run the flashcard-review workflow" - =page-me.org= — set a timed notification. - Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm") - =status-check.org= — proactive long-running-job updates. - Triggers: "keep me posted on this", "provide status checks on this job", "let me know when it's done", "monitor this for me". Auto: any job estimated 10+ min. - =create-workflow.org= — define a new workflow. - Triggers: "let's create/define/design a workflow for [activity]", or unmatched workflow request after this index returns no hit. +- =rename-artifact.org= — rename an =.ai/= workflow or script across the canonical + mirror trees, rewriting every reference on a token boundary and leaving =sessions/= history alone. Backed by =scripts/rename-ai-artifact.sh=, which runs =workflow-integrity= + =sync-check= after the move. + - Triggers: "rename this workflow", "rename the [X] workflow/script", "let's run the rename-artifact workflow". - =no-approvals.org= — drop the interaction-level approval gates for a pre-agreed batch while keeping engineering-discipline gates (=/review-code=, =/voice personal=, tests, session-log updates, subagent reviews, destructive-action consent). Mode stays on until Craig turns it off, a real question arises, the queue empties, or the conversation switches topics. - Triggers: "no-approvals mode", "no approvals", "no-approval", "no need for approval gates", "stop asking, just keep going", "I'll check back in when you're done or stuck", "do all == with no-approval" - =cross-agent-comms.org= — protocol for cross-project agent coordination via =inbox/from-agents/= (file-based IPC, GPG-signed, supports cross-machine over Tailscale). Auto: when =cross-agent-watch= detects a new inbound message, or when an agent decides to initiate a cross-project conversation. Operational scripts (=cross-agent-send=, =-recv=, =-watch=, =-status=, =-discover=, =-halt=, =-resume=) and their READMEs live at =.ai/scripts/cross-agent-comms/=. diff --git a/.ai/workflows/drill-deck-review.org b/.ai/workflows/drill-deck-review.org deleted file mode 100644 index 390f296..0000000 --- a/.ai/workflows/drill-deck-review.org +++ /dev/null @@ -1,327 +0,0 @@ -#+TITLE: Drill Deck Review Workflow -#+AUTHOR: Craig Jennings & Claude -#+DATE: 2026-05-30 - -* Overview - -Take an org-drill flashcard file and bring it into the canonical shape — every card a question that doesn't give the answer away, every fact current — then regenerate the Anki =.apkg= and drop it where the phone can sync it. - -The workflow has three substantive passes (question-form audit, content-accuracy audit, source rewrite) followed by a mechanical regenerate-and-place step. Content review is dispatched to a subagent because it's bounded research across project source-of-truth files; the structural rewrite stays in the main thread because it touches the SRS state we don't want to lose. Three helper scripts (=drill-deck-stats.py=, =drill-deck-diff-ids.py=, =drill-deck-sync=) automate the inventory, the safety check, and the regenerate-and-place. - -*Scheduling lives on the Anki side.* Desired retention and the FSRS scheduling model are per-deck Anki options set on the phone, never controlled by the org source or =drill-to-anki.py=. The pipeline's only scheduling job is keeping each card's identity (the =:ID:=-derived GUID) stable so Anki's review history survives a rewrite. Don't try to encode retention, intervals, or org-drill's SM-2 state into the Anki output — the two schedulers are separate, and the import carries only card content plus identity. (Anki's desired-retention default is 90%; see [[https://docs.ankiweb.net/deck-options.html][the deck-options manual]].) - -* When to Use This Workflow - -Trigger phrases: - -- "Review the drill deck" -- "Update the drill deck" -- "Refresh the Anki cards" -- "Let's run the drill-deck-review workflow" - -Typical timing: - -- After a wave of personnel changes (titles, roles, employment status) -- After a major milestone (a demo ships, a contract closes, a submission goes in) -- When org-drill review surfaces a card with stale or wrong content -- When the Anki deck on the phone hasn't been regenerated in weeks - -* Inputs - -- *Source file*: the org-drill file. Common locations: - - =deepsat.org= at the work project root (symlinked from =~/sync/org/drill/=) - - =health-drill.org= in the health project - - Any =:drill:= deck under =~/sync/org/drill/= -- *Source-of-truth docs for content accuracy*: project-specific. Typical set: - - Project-root =knowledge.org=, =status.org=, =notes.org= - - =todo.org= for the freshest signal on people / partnerships / projects - - =deepsat/assets/= (or equivalent) for meeting transcripts when a specific fact needs confirmation -- *Output location*: =~/sync/phone/anki/.apkg= (the phone-sync target). Both =drill-to-anki.py= and the =drill-deck-sync= wrapper default there. - -* Canonical Card Shape - -** Deck title (=#+TITLE:= line) - -The =#+TITLE:= line at the top of the source file drives two surfaces: the org-drill display in Emacs and the Anki deck name on the phone. Pick a title that reads well in Anki — drop tool-name jargon like "Org-Drill" / "Drill" that's meaningful in Emacs but noise on the consumption side. - -Good: =DeepSat Flashcards=, =Health Flashcards=, =Philosophy Flashcards=. -Bad: =DeepSat Org-Drill Flashcards=, =DeepSat Drill Deck=. - -=drill-deck-stats.py= flags any title containing =org-drill= (case-insensitive, hyphenated or spaced) as a workflow violation. - -*Stable-ID caveat.* =drill-to-anki.py= derives the Anki deck ID from the deck name. Changing =#+TITLE:= changes the deck ID, so the next import lands as a new deck rather than updating the existing one. Two consequences worth flagging: - -- Any review history accumulated in Anki under the old deck name stays attached to the old deck — it doesn't migrate. -- On rename, delete the old deck from Anki to avoid having two decks with similar content. - -For most decks (especially on first deployment), this is a one-time event. The rename is cheap to do early. - -** Heading (the question) - -Every card heading is a question that doesn't reveal the answer. Not the topic name, not the acronym, not the person's name — a question that tests recall. - -Three card families have different question shapes: - -*** Acronym / concept cards -"What does X stand for and what is it?" or "What is X and why does it matter?" Promote the question that was already in the body up to the heading. - - Before: - : ** AFRL :drill: - : What does AFRL stand for and what is it? - : *** Answer - : Air Force Research Laboratory. ... - - After: - : ** What does AFRL stand for and what is it? :drill: - : Air Force Research Laboratory. ... - -*** Person cards -Format: "Who is X? Tell me about their Y." where X is a role descriptor that doesn't name the person, and Y is whatever the answer body covers (background, role, limitations, scope). The answer body opens by naming the person, then continues. - - Before: - : ** Vrezh Mikayelyan :drill: - : Who is Vrezh? What are his key limitations? - : *** Answer - : Developer (also called "Reg"). Armenia-based ... - - After: - : ** Who is DeepSat's Armenia-based developer? Tell me about his background and limitations. :drill: - : Vrezh Mikayelyan. Armenia-based, full-time as of April 2026. Worked with Hayk at Bazoomq on Armenia's first satellite ... - - Note: pick a role descriptor that genuinely identifies one person. If multiple people share the role description, add a single distinguishing detail (e.g., "the one who works evenings", "the Vineti alum"). Don't pile on parentheticals. - - Splitting: the person card deliberately trades atomicity for narrative recall — one card carries identity plus several attributes. When a body bundles genuinely unrelated attributes (role, employment history, limitations, scope) rather than one coherent topic, split it into multiple cards. One inherits the existing =:ID:= (and its SRS history); each new sibling starts fresh and will correctly show in =drill-deck-diff-ids.py= as an appeared ID. The criterion: split when the body reads as a list of separate facts, keep it whole when it reads as one story. (Minimum-information principle — Wozniak rule 4, Matuschak "Focused".) - -*** Talking-points and directive cards -Already in prompt form ("Introduce Yourself", "Spell out these orbital regime acronyms", "What is DeepSat?"). Leave the heading alone. Still strip the =*** Answer= sub-header and audit the body content for staleness. - -The =drill-deck-stats.py= helper recognizes both =?=-form and imperative-verb form as valid prompts (verbs like Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize). - -** Body (the answer) - -- *No =*** Answer= sub-header.* The body /is/ the answer; the heading /is/ the question. The sub-header was a workaround for topic-as-heading cards. -- *Body opens by naming the topic.* "Air Force Research Laboratory. Air Force's R&D arm." or "Vrezh Mikayelyan. Armenia-based, full-time as of ..." The Anki back shows this directly under the front question; restating the topic makes the back read as a complete answer. -- *PROPERTIES drawer stays.* Org-drill needs the =:ID:=, =:DRILL_LAST_INTERVAL:=, =:DRILL_EASE:= etc. for SRS state. The Anki output strips it (see the script change). -- *=SCHEDULED:= / =DEADLINE:= planning lines stay.* Same reason. The Anki output strips them. -- *Source citation goes at the very end, after two blank lines.* When a card cites a source, put a =Source: