aware/aware.py
2026-05-02 21:22:18 -04:00

880 lines
31 KiB
Python
Executable file

#!/usr/bin/env python3
"""
AWARE — Aviation Weighted Active Recall Engine
A cross-platform CLI study tool with spaced repetition and text-to-speech.
Features:
- 5-bucket weighted repetition (New/Missed -> Learning -> Known)
- Cross-platform TTS (Linux: espeak-ng, macOS: say, Windows: SAPI)
- Toggle voice on/off with 'v' at any time
- Multi-deck support: place .json files in the decks/ folder
- Per-deck progress saved to ~/.aware/progress/
- Category filtering, custom session lengths, missed-only review
- Optional config override: ~/.aware/config.json
- CAS message display and extended detail fields
Deck JSON schema (minimal):
{
"title": "My Study Deck",
"questions": [
{"question": "What is X?", "answer": "X is Y."}
]
}
Full schema (with categories and refs):
{
"title": "My Study Deck",
"categories": [
{
"name": "Topic A",
"questions": [
{"id": 1, "question": "...", "answer": "...", "ref": "..."}
]
}
]
}
"""
import json
import hashlib
import os
import random
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
# ── Configuration ──────────────────────────────────────────────────────────
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DECKS_DIR = os.path.join(SCRIPT_DIR, "decks")
PROGRESS_DIR = os.path.expanduser("~/.aware/progress")
BUCKET_WEIGHTS = {1: 8, 2: 5, 3: 3, 4: 2, 5: 1}
BUCKET_LABELS = {1: "New / No Clue", 2: "Some", 3: "Half", 4: "Most", 5: "Nailed It"}
BUCKET_COLORS = {1: "RED", 2: "RED", 3: "YELLOW", 4: "GREEN", 5: "GREEN"}
# Grade input mapping: user input -> bucket assignment
# n and 1-3 set absolute bucket positions; y promotes by one
GRADE_TO_BUCKET = {"n": 1, "1": 2, "2": 3, "3": 4}
CONFIG_FILE = os.path.expanduser("~/.aware/config.json")
GLOSSARY_FILE = os.path.join(SCRIPT_DIR, "tts_glossary.json")
# ── Platform-specific TTS defaults ─────────────────────────────────────────
#
# Each backend is a dict with:
# cmd - executable name or path
# check - args to verify the tool exists (run with subprocess)
# build_args- function(text) -> full arg list for Popen
# label - human-readable name for status messages
# install - install hint shown when tool is missing
def _linux_tts():
return {
"cmd": "espeak-ng",
"check": ["espeak-ng", "--version"],
"build_args": lambda text: [
"espeak-ng", "-v", "en-us", "-s", "160", "--", text
],
"label": "espeak-ng",
"install": "sudo apt install espeak-ng",
}
def _macos_tts():
return {
"cmd": "say",
"check": ["which", "say"],
"build_args": lambda text: [
"say", "-v", "Alex", "-r", "180", text
],
"label": "macOS say",
"install": "say is built into macOS — check your PATH",
}
def _windows_tts():
# PowerShell's System.Speech is synchronous in-process but async from
# Python's perspective since we launch it as a subprocess. Killing the
# PowerShell process stops speech immediately.
def _build(text):
# Escape single quotes for PowerShell string literal
escaped = text.replace("'", "''")
ps_script = (
"Add-Type -AssemblyName System.Speech; "
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
f"$s.Speak('{escaped}')"
)
return ["powershell", "-NoProfile", "-Command", ps_script]
return {
"cmd": "powershell",
"check": ["powershell", "-NoProfile", "-Command", "echo ok"],
"build_args": _build,
"label": "Windows SAPI",
"install": "PowerShell should be available on Windows by default",
}
def _detect_tts_backend():
"""Pick the right TTS backend for the current platform."""
if sys.platform == "darwin":
return _macos_tts()
elif sys.platform == "win32":
return _windows_tts()
else:
return _linux_tts()
# ── ANSI Colors ────────────────────────────────────────────────────────────
class C:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
CYAN = "\033[36m"
RED = "\033[31m"
MAGENTA = "\033[35m"
WHITE = "\033[97m"
@classmethod
def init(cls):
"""Disable color codes on Windows without ANSI support."""
if sys.platform == "win32":
try:
os.system("") # enables ANSI on Win10+
except Exception:
# Fallback: strip all color
for attr in ("RESET", "BOLD", "DIM", "GREEN", "YELLOW",
"CYAN", "RED", "MAGENTA", "WHITE"):
setattr(cls, attr, "")
# ── TTS Config Override ───────────────────────────────────────────────────
def _load_tts_config():
"""
Load optional user config from ~/.aware/config.json.
Supported keys:
tts_command - executable path/name (e.g. "piper", "espeak-ng")
tts_args - list of args; use {text} as placeholder for spoken text
e.g. ["--voice", "en_US", "{text}"]
If present, overrides the autodetected platform default.
"""
if not os.path.exists(CONFIG_FILE):
return None
try:
with open(CONFIG_FILE, "r") as f:
cfg = json.load(f)
if "tts_command" not in cfg:
return None
cmd = cfg["tts_command"]
args_template = cfg.get("tts_args", ["{text}"])
return {
"cmd": cmd,
"check": [cmd, "--version"],
"build_args": lambda text, _t=args_template, _c=cmd: (
[_c] + [a.replace("{text}", text) for a in _t]
),
"label": f"custom ({cmd})",
"install": f"Configured via {CONFIG_FILE}",
}
except Exception:
return None
# ── TTS Engine ─────────────────────────────────────────────────────────────
class TTS:
"""
Cross-platform text-to-speech.
Autodetects:
Linux -> espeak-ng
macOS -> say
Windows -> PowerShell System.Speech (SAPI)
Override via ~/.aware/config.json:
{"tts_command": "piper", "tts_args": ["--model", "en", "{text}"]}
"""
def __init__(self):
self.enabled = False
self._proc = None
# Try user config override first, then platform default
custom = _load_tts_config()
if custom:
self._backend = custom
else:
self._backend = _detect_tts_backend()
self.available = self._check_available()
self.label = self._backend["label"]
# Load pronunciation glossary
self._abbreviations = {}
self._symbols = {}
self._abbr_pattern = None
self._load_glossary()
def _load_glossary(self):
"""
Load pronunciation glossary from tts_glossary.json.
Falls back to a minimal built-in set if the file is missing.
"""
glossary_path = GLOSSARY_FILE
if os.path.exists(glossary_path):
try:
with open(glossary_path, "r") as f:
data = json.load(f)
self._abbreviations = data.get("abbreviations", {})
self._symbols = data.get("symbols", {})
except Exception:
self._abbreviations = {}
self._symbols = {}
# Compile a single regex that matches any abbreviation as a whole word.
# Sort by length descending so longer abbreviations match first
# (e.g. "KCAS" before "CAS", "OHPTS" before "HP").
if self._abbreviations:
sorted_abbrs = sorted(self._abbreviations.keys(),
key=len, reverse=True)
escaped = [re.escape(a) for a in sorted_abbrs]
self._abbr_pattern = re.compile(
r'\b(' + '|'.join(escaped) + r')\b'
)
def _check_available(self):
try:
result = subprocess.run(
self._backend["check"],
capture_output=True, timeout=5
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return False
def toggle(self):
if not self.available:
return False
self.enabled = not self.enabled
return self.enabled
def speak(self, text):
if not self.enabled or not self.available:
return
self.stop()
clean = self._clean_for_speech(text)
if not clean.strip():
return
try:
args = self._backend["build_args"](clean)
# On Windows, hide the PowerShell window
kwargs = {}
if sys.platform == "win32":
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = 0 # SW_HIDE
kwargs["startupinfo"] = si
self._proc = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**kwargs
)
except Exception:
pass
def stop(self):
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
self._proc.wait(timeout=2)
except Exception:
try:
self._proc.kill()
except Exception:
pass
self._proc = None
def install_hint(self):
return self._backend["install"]
def _clean_for_speech(self, text):
"""
Prepare text for TTS:
1. Strip ANSI escape codes
2. Replace abbreviations using word-boundary regex from glossary
3. Replace symbols (>=, °, etc.) from glossary
"""
# Strip ANSI
text = re.sub(r'\033\[[0-9;]*m', '', text)
# Replace abbreviations at word boundaries only
if self._abbr_pattern:
text = self._abbr_pattern.sub(
lambda m: self._abbreviations[m.group(0)], text
)
# Replace symbols (order matters: >= before >)
for sym, replacement in sorted(self._symbols.items(),
key=lambda x: len(x[0]),
reverse=True):
text = text.replace(sym, replacement)
return text
# ── Deck Discovery & Loading ───────────────────────────────────────────────
def discover_decks():
decks = []
if os.path.isdir(DECKS_DIR):
for f in sorted(os.listdir(DECKS_DIR)):
if f.endswith(".json"):
path = os.path.join(DECKS_DIR, f)
info = _peek_deck(path)
if info:
decks.append(info)
return decks
def _peek_deck(path):
try:
with open(path, "r") as f:
data = json.load(f)
title = data.get("title", Path(path).stem)
count = _count_questions(data)
if count == 0:
return None
return {"path": path, "title": title, "count": count, "filename": os.path.basename(path)}
except Exception:
return None
def _count_questions(data):
if "categories" in data:
return sum(len(cat.get("questions", [])) for cat in data["categories"])
elif "questions" in data:
return len(data["questions"])
return 0
def load_deck(path):
"""
Load a deck from JSON. Supports two formats:
1. Categorized: {"categories": [{"name": "...", "questions": [...]}]}
2. Flat: {"questions": [{"question": "...", "answer": "..."}]}
Returns (question_list, category_names, title).
"""
with open(path, "r") as f:
data = json.load(f)
title = data.get("title", Path(path).stem)
questions = []
categories = []
auto_id = 1
if "categories" in data:
for cat in data["categories"]:
cat_name = cat.get("name", "Uncategorized")
categories.append(cat_name)
for q in cat.get("questions", []):
if "id" not in q:
q["id"] = auto_id
auto_id += 1
q.setdefault("category", cat_name)
q.setdefault("ref", "")
questions.append(q)
elif "questions" in data:
categories.append("General")
for q in data["questions"]:
if "id" not in q:
q["id"] = auto_id
auto_id += 1
q.setdefault("category", "General")
q.setdefault("ref", "")
questions.append(q)
return questions, categories, title
# ── Progress Persistence ───────────────────────────────────────────────────
def _progress_path(deck_path):
deck_hash = hashlib.md5(os.path.abspath(deck_path).encode()).hexdigest()[:12]
deck_name = Path(deck_path).stem
os.makedirs(PROGRESS_DIR, exist_ok=True)
return os.path.join(PROGRESS_DIR, f"{deck_name}_{deck_hash}.json")
def load_progress(deck_path):
ppath = _progress_path(deck_path)
if os.path.exists(ppath):
with open(ppath, "r") as f:
return json.load(f)
return {"buckets": {}, "stats": {"total_sessions": 0, "total_correct": 0, "total_wrong": 0}}
def save_progress(deck_path, progress):
ppath = _progress_path(deck_path)
with open(ppath, "w") as f:
json.dump(progress, f, indent=2)
# ── Bucket Management ──────────────────────────────────────────────────────
def get_bucket(progress, qid):
return progress["buckets"].get(str(qid), 1)
def set_bucket(progress, qid, bucket):
"""Set a question to a specific bucket (1-5)."""
progress["buckets"][str(qid)] = max(1, min(bucket, 5))
def grade_question(progress, qid, grade):
"""
Apply a grade to a question and return the new bucket.
Grades:
'n' -> bucket 1 (absolute)
'1' -> bucket 2 (absolute)
'2' -> bucket 3 (absolute)
'3' -> bucket 4 (absolute)
'y' -> promote by 1, max 5 (relative)
"""
if grade in GRADE_TO_BUCKET:
new_bucket = GRADE_TO_BUCKET[grade]
elif grade == "y":
new_bucket = min(get_bucket(progress, qid) + 1, 5)
else:
return get_bucket(progress, qid)
set_bucket(progress, qid, new_bucket)
return new_bucket
def pick_weighted(questions, progress):
weights = [BUCKET_WEIGHTS[get_bucket(progress, q["id"])] for q in questions]
return random.choices(questions, weights=weights, k=1)[0]
# ── Display Helpers ────────────────────────────────────────────────────────
def term_width():
try:
return min(os.get_terminal_size().columns, 80)
except OSError:
return 80
def clear_screen():
os.system("clear" if os.name != "nt" else "cls")
def print_header(text, tts=None):
width = term_width()
print(f"\n{C.CYAN}{'=' * width}{C.RESET}")
line = f" {text}"
if tts:
if tts.enabled:
state = f"{C.GREEN}ON ({tts.label}){C.RESET}"
else:
state = f"{C.DIM}OFF{C.RESET}"
line += f" {C.DIM}|{C.RESET} Voice: {state}"
print(f"{C.BOLD}{C.WHITE}{line}{C.RESET}")
print(f"{C.CYAN}{'=' * width}{C.RESET}")
def print_question(q, num, total, progress):
bucket = get_bucket(progress, q["id"])
bucket_label = BUCKET_LABELS[bucket]
color_map = {1: C.RED, 2: C.RED, 3: C.YELLOW, 4: C.GREEN, 5: C.GREEN}
bucket_color = color_map[bucket]
cat = q.get("category", "")
cat_str = f" Category: {C.CYAN}{cat}{C.RESET}" if cat and cat != "General" else ""
print(f"\n{C.DIM}[{num}/{total}]{cat_str}"
f" {C.DIM}| Bucket: {bucket_color}{bucket_label}{C.RESET}")
# Display CAS messages if present
cas = q.get("cas")
if cas:
if isinstance(cas, str):
cas = [cas]
print(f"\n {C.BOLD}{C.WHITE}CAS:{C.RESET}")
for msg in cas:
print(f" {msg}")
print(f"\n{C.BOLD}{C.WHITE} Q: {q['question']}{C.RESET}\n")
def print_answer(q):
print(f"{C.GREEN}{C.BOLD} A: {C.RESET}{C.GREEN}{q['answer']}{C.RESET}")
if q.get("ref"):
print(f"{C.DIM} Ref: {q['ref']}{C.RESET}")
if q.get("detail"):
print(f"\n {C.CYAN}{C.BOLD}Detail:{C.RESET}")
print(f" {C.CYAN}{q['detail']}{C.RESET}")
def print_session_stats(grades, total):
width = term_width()
nailed = grades.get("y", 0)
missed = grades.get("n", 0)
partial = grades.get("1", 0) + grades.get("2", 0) + grades.get("3", 0)
# Score: y=100%, 3=75%, 2=50%, 1=25%, n=0%
score_sum = (nailed * 100 + grades.get("3", 0) * 75 +
grades.get("2", 0) * 50 + grades.get("1", 0) * 25)
avg = (score_sum / total) if total > 0 else 0
print(f"\n{C.CYAN}{'-' * width}{C.RESET}")
print(f" {C.BOLD}Session Results ({total} questions):{C.RESET}")
print(f" {C.GREEN}Nailed (y): {nailed}{C.RESET} | "
f"{C.YELLOW}Partial (1/2/3): {partial}{C.RESET} | "
f"{C.RED}Missed (n): {missed}{C.RESET} | "
f"{C.BOLD}Avg: {avg:.0f}%{C.RESET}")
print(f"{C.CYAN}{'-' * width}{C.RESET}")
def print_overall_stats(progress, questions):
buckets = {i: 0 for i in range(1, 6)}
for q in questions:
buckets[get_bucket(progress, q["id"])] += 1
total = len(questions)
print(f"\n {C.BOLD}Progress ({total} questions):{C.RESET}")
print(f" {C.RED}Bucket 1 (No Clue): {buckets[1]:>4} ({buckets[1]/total*100:5.1f}%){C.RESET}")
print(f" {C.RED}Bucket 2 (Some): {buckets[2]:>4} ({buckets[2]/total*100:5.1f}%){C.RESET}")
print(f" {C.YELLOW}Bucket 3 (Half): {buckets[3]:>4} ({buckets[3]/total*100:5.1f}%){C.RESET}")
print(f" {C.GREEN}Bucket 4 (Most): {buckets[4]:>4} ({buckets[4]/total*100:5.1f}%){C.RESET}")
print(f" {C.GREEN}Bucket 5 (Nailed): {buckets[5]:>4} ({buckets[5]/total*100:5.1f}%){C.RESET}")
s = progress["stats"]
if s["total_sessions"] > 0:
lifetime = s.get("total_correct", 0) + s.get("total_wrong", 0) + s.get("total_partial", 0)
if lifetime > 0:
lifetime_pct = (s.get("total_correct", 0) / lifetime * 100)
print(f"\n {C.DIM}Sessions: {s['total_sessions']} | "
f"Lifetime: {s.get('total_correct',0)} nailed / "
f"{s.get('total_partial',0)} partial / "
f"{s.get('total_wrong',0)} missed ({lifetime_pct:.0f}% nailed){C.RESET}")
# ── Category Selection ─────────────────────────────────────────────────────
def select_categories(categories):
print(f"\n {C.BOLD}Categories:{C.RESET}")
print(f" {C.CYAN}0{C.RESET}) All categories")
for i, cat in enumerate(categories, 1):
print(f" {C.CYAN}{i}{C.RESET}) {cat}")
print(f"\n Enter numbers separated by commas (e.g. 1,3,5) or 0 for all:")
try:
raw = input(f" {C.BOLD}>{C.RESET} ").strip()
except (EOFError, KeyboardInterrupt):
return None
if not raw or raw == "0":
return None
selected = []
for part in raw.split(","):
part = part.strip()
if part.isdigit():
idx = int(part)
if 1 <= idx <= len(categories):
selected.append(categories[idx - 1])
return selected if selected else None
# ── Quiz Loop ──────────────────────────────────────────────────────────────
def run_quiz(questions, deck_path, progress, count, title, tts):
grades = {"n": 0, "1": 0, "2": 0, "3": 0, "y": 0}
for i in range(1, count + 1):
clear_screen()
print_header(title, tts)
pick = pick_weighted(questions, progress)
print_question(pick, i, count, progress)
# Build spoken text with CAS context if present
speak_text = ""
cas = pick.get("cas")
if cas:
if isinstance(cas, str):
cas = [cas]
speak_text = "CAS: " + ", ".join(cas) + ". "
speak_text += pick["question"]
tts.speak(speak_text)
input(f" {C.DIM}[Press Enter to reveal answer]{C.RESET}")
tts.stop()
print_answer(pick)
print(f"\n {C.BOLD}How'd you do?{C.RESET}")
print(f" {C.RED}n{C.RESET} - didn't get it "
f"{C.YELLOW}1{C.RESET} - some "
f"{C.YELLOW}2{C.RESET} - half "
f"{C.YELLOW}3{C.RESET} - most "
f"{C.GREEN}y{C.RESET} - nailed it")
print(f" {C.MAGENTA}v{C.RESET} - voice toggle "
f"{C.DIM}q - quit{C.RESET}")
while True:
try:
resp = input(f" {C.BOLD}>{C.RESET} ").strip().lower()
except (EOFError, KeyboardInterrupt):
resp = "q"
if resp in ("y", "yes", "+"):
grade = "y"
elif resp in ("n", "no", "-", ""):
grade = "n"
elif resp in ("1", "2", "3"):
grade = resp
elif resp in ("v", "voice"):
new_state = tts.toggle()
label = f"{C.GREEN}ON{C.RESET}" if new_state else f"{C.DIM}OFF{C.RESET}"
print(f" {C.MAGENTA}Voice: {label}{C.RESET}")
if new_state:
tts.speak(speak_text)
continue
elif resp in ("q", "quit"):
tts.stop()
print_session_stats(grades, sum(grades.values()))
save_progress(deck_path, progress)
return
else:
print(f" {C.DIM}Enter n/-, 1, 2, 3, y/+, v, or q{C.RESET}")
continue
# Apply grade
grades[grade] += 1
new_bucket = grade_question(progress, pick["id"], grade)
new_label = BUCKET_LABELS[new_bucket]
if grade == "y":
progress["stats"]["total_correct"] = progress["stats"].get("total_correct", 0) + 1
print(f" {C.GREEN}+ Nailed it — {new_label}{C.RESET}")
elif grade == "n":
progress["stats"]["total_wrong"] = progress["stats"].get("total_wrong", 0) + 1
print(f" {C.RED}x Missed — {new_label}{C.RESET}")
else:
progress["stats"]["total_partial"] = progress["stats"].get("total_partial", 0) + 1
print(f" {C.YELLOW}~ Partial — {new_label}{C.RESET}")
break
time.sleep(0.6)
progress["stats"]["total_sessions"] += 1
save_progress(deck_path, progress)
tts.stop()
clear_screen()
print_header("Session Complete", tts)
print_session_stats(grades, count)
print_overall_stats(progress, questions)
def review_missed(questions, deck_path, progress, title, tts):
missed = [q for q in questions if get_bucket(progress, q["id"]) == 1]
if not missed:
print(f"\n {C.GREEN}No questions in Bucket 1 — nothing to review.{C.RESET}")
return
random.shuffle(missed)
print(f"\n {C.BOLD}Reviewing {len(missed)} missed/new questions...{C.RESET}")
time.sleep(1)
run_quiz(missed, deck_path, progress, min(len(missed), 50), title, tts)
# ── Deck Selection ─────────────────────────────────────────────────────────
def select_deck():
decks = discover_decks()
if not decks:
print(f"\n {C.RED}No decks found.{C.RESET}")
print(f" Place .json deck files in: {C.CYAN}{DECKS_DIR}/{C.RESET}")
print(f"\n Minimal deck format:")
print(f' {C.DIM}{{"title": "My Deck", "questions": '
f'[{{"question": "Q?", "answer": "A."}}]}}{C.RESET}')
return None
if len(decks) == 1:
return decks[0]["path"]
print(f"\n {C.BOLD}Available Decks:{C.RESET}\n")
for i, d in enumerate(decks, 1):
print(f" {C.CYAN}{i}{C.RESET}) {d['title']} "
f"{C.DIM}({d['count']} questions — {d['filename']}){C.RESET}")
print()
try:
raw = input(f" Select deck {C.BOLD}>{C.RESET} ").strip()
except (EOFError, KeyboardInterrupt):
return None
if raw.isdigit():
idx = int(raw)
if 1 <= idx <= len(decks):
return decks[idx - 1]["path"]
return None
# ── Main Menu ──────────────────────────────────────────────────────────────
def deck_menu(deck_path, tts):
questions, categories, title = load_deck(deck_path)
progress = load_progress(deck_path)
while True:
clear_screen()
print_header(title, tts)
print_overall_stats(progress, questions)
multi_cat = len(categories) > 1
print(f"\n {C.BOLD}Menu:{C.RESET}")
print(f" {C.CYAN}1{C.RESET}) Quick 10")
print(f" {C.CYAN}2{C.RESET}) Session 25")
print(f" {C.CYAN}3{C.RESET}) Full 50")
print(f" {C.CYAN}4{C.RESET}) Custom count")
if multi_cat:
print(f" {C.CYAN}5{C.RESET}) By category")
print(f" {C.CYAN}6{C.RESET}) Review missed only (Bucket 1)")
voice_state = f"{C.GREEN}ON{C.RESET}" if tts.enabled else f"{C.DIM}OFF{C.RESET}"
print(f" {C.MAGENTA}v{C.RESET}) Toggle voice ({voice_state})")
print(f" {C.CYAN}7{C.RESET}) Reset deck progress")
print(f" {C.CYAN}d{C.RESET}) Switch deck")
print(f" {C.CYAN}q{C.RESET}) Quit")
try:
choice = input(f"\n {C.BOLD}>{C.RESET} ").strip().lower()
except (EOFError, KeyboardInterrupt):
break
if choice == "1":
run_quiz(questions, deck_path, progress,
min(10, len(questions)), title, tts)
elif choice == "2":
run_quiz(questions, deck_path, progress,
min(25, len(questions)), title, tts)
elif choice == "3":
run_quiz(questions, deck_path, progress,
min(50, len(questions)), title, tts)
elif choice == "4":
try:
n = int(input(f" How many questions? (1-{len(questions)}): ").strip())
n = max(1, min(n, len(questions)))
except (ValueError, EOFError, KeyboardInterrupt):
continue
run_quiz(questions, deck_path, progress, n, title, tts)
elif choice == "5" and multi_cat:
selected = select_categories(categories)
filtered = ([q for q in questions if q["category"] in selected]
if selected else questions)
if not filtered:
print(f" {C.RED}No questions matched.{C.RESET}")
time.sleep(1)
continue
try:
n = int(input(f" How many? (1-{len(filtered)}, default 10): "
).strip() or "10")
n = max(1, min(n, len(filtered)))
except (ValueError, EOFError, KeyboardInterrupt):
continue
run_quiz(filtered, deck_path, progress, n, title, tts)
elif choice == "6":
review_missed(questions, deck_path, progress, title, tts)
elif choice in ("v", "voice"):
new_state = tts.toggle()
if not tts.available:
print(f" {C.RED}TTS not available ({tts.label}). "
f"{tts.install_hint()}{C.RESET}")
time.sleep(2)
else:
label = (f"{C.GREEN}ON{C.RESET}" if new_state
else f"{C.DIM}OFF{C.RESET}")
print(f" {C.MAGENTA}Voice: {label}{C.RESET}")
time.sleep(0.5)
continue
elif choice == "7":
try:
confirm = input(
f" {C.RED}Reset progress for this deck? "
f"(type YES): {C.RESET}"
).strip()
except (EOFError, KeyboardInterrupt):
continue
if confirm == "YES":
progress = {
"buckets": {},
"stats": {"total_sessions": 0,
"total_correct": 0,
"total_wrong": 0}
}
save_progress(deck_path, progress)
print(f" {C.GREEN}Progress reset.{C.RESET}")
time.sleep(1)
continue
elif choice == "d":
save_progress(deck_path, progress)
return "switch"
elif choice in ("q", "quit"):
break
else:
continue
input(f"\n {C.DIM}[Press Enter to return to menu]{C.RESET}")
save_progress(deck_path, progress)
return "quit"
def main():
C.init()
os.makedirs(DECKS_DIR, exist_ok=True)
# Auto-migrate legacy file from script directory into decks/
if not discover_decks():
for f in os.listdir(SCRIPT_DIR):
if f.endswith(".json") and f != "package.json":
src = os.path.join(SCRIPT_DIR, f)
info = _peek_deck(src)
if info:
print(f" {C.YELLOW}Moving {f} into decks/ ...{C.RESET}")
shutil.copy2(src, os.path.join(DECKS_DIR, f))
if not discover_decks():
print(f"\n {C.RED}No decks found.{C.RESET}")
print(f" Place .json deck files in: {C.CYAN}{DECKS_DIR}/{C.RESET}")
print(f"\n Minimal format:")
print(f' {C.DIM}{{"title": "My Deck", '
f'"questions": [{{"question": "Q?", "answer": "A."}}]}}{C.RESET}\n')
sys.exit(1)
tts = TTS()
while True:
clear_screen()
print_header("AWARE — Aviation Weighted Active Recall Engine", tts)
deck_path = select_deck()
if deck_path is None:
break
result = deck_menu(deck_path, tts)
if result == "quit":
break
tts.stop()
print(f"\n {C.DIM}Progress saved. Fly safe.{C.RESET}\n")
if __name__ == "__main__":
main()