880 lines
31 KiB
Python
Executable file
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()
|