#!/usr/bin/env python3
"""
Cross-Course Curriculum Verifier
=================================
Runs automated quality checks on all 2nd grade courses.
Checks per course:
ELA: phonics accuracy, spelling, reading passage level
Math: answer key verification (same-line only)
History: anachronism detection, factual red flags
Science: safety checks, scientific accuracy spot-checks
All courses: reading level tracked (reported as summary stats, not flagged individually).
Usage:
python3 verify_curriculum.py # all courses
python3 verify_curriculum.py --course math
python3 verify_curriculum.py --report # save JSON
"""
import fitz, glob, os, re, json, sys, argparse
from collections import defaultdict
from datetime import datetime
BASE = os.path.expanduser('~/Home_School/2nd_Grade/')
# āāā Flesch-Kincaid Grade Level āāā
def count_syllables(word):
"""Count syllables in a word."""
w = word.lower().strip()
if len(w) <= 3:
return 1
vowels = 'aeiouy'
count = 0
prev_vowel = False
for c in w:
is_vowel = c in vowels
if is_vowel and not prev_vowel:
count += 1
prev_vowel = is_vowel
if w.endswith('e') and count > 1:
count -= 1
if w.endswith('le') and len(w) > 2 and w[-3] not in vowels:
count += 1
return max(count, 1)
def fk_grade(text):
"""Estimate Flesch-Kincaid grade level."""
sentences = re.findall(r'[^.!?]+[.!?]+', text)
words = re.findall(r'[a-zA-Z]+', text)
if not words or len(words) < 10:
return None
total_s = max(len(sentences), 1)
total_w = len(words)
total_sy = sum(count_syllables(w) for w in words)
return round(0.39 * (total_w / total_s) + 11.8 * (total_sy / total_w) - 15.59, 1)
# āāā Shared helpers āāā
def strip_html(text):
return re.sub(r'<[^>]+>', '', text)
def get_student_pdfs(course_dir):
"""Get all student PDFs recursively (exclude Teacher Guides)."""
all_pdfs = sorted(glob.glob(os.path.join(course_dir, '**', '*.pdf'), recursive=True))
return [p for p in all_pdfs if 'Teacher' not in p and 'teacher' not in p]
def extract_week_day(pdf_path):
"""Extract week number and day from PDF path."""
week_match = re.search(r'[Ww]eek[_\-]?(\d+)', pdf_path)
week = int(week_match.group(1)) if week_match else 0
day_match = re.search(r'(Monday|Tuesday|Wednesday|Thursday|Friday)', pdf_path, re.I)
day = day_match.group(1).capitalize() if day_match else 'Unknown'
return week, day
def get_passage_fks(text):
"""Extract narrative passages and return their FK scores (filtered).
Returns list of FK scores for passages with 25+ words."""
text_clean = strip_html(text)
text_joined = ' '.join(text_clean.split())
sentences = re.findall(r'[^.!?]+[.!?]+', text_joined)
# Sentences that look like instructions, not narrative
instruction_kw = [
r'read\s+(each|the)', r'write\s+(a|each|your|sentences)',
r'circle\s+(each|all)', r'underline', r'fill\s+(in|out)',
r'match\s+(each|the)', r'sort\s+(these|the)', r'copy\s+(it|each)',
r'unscramble', r'name\s*:', r'here\s*\'?s?\s*what',
r'what\s+we\s+are\s+learning', r'word\s+cards', r'copy\s+practice',
r'spelling\s+detective', r'grammar\s*:', r'phonics\s*:',
r'pattern\s+hunt', r'word\s+builder', r'write\s+your\s+answer',
r'this\s+week', r'vowel\s+(team|teams)',
r'review\s+questions', r'estimate\s+first', r'count\s+on',
r'fill\s+in\s+the\s+missing', r'today\s+you\s+will',
r'today\s+we\s+will', r'answer\s+the\s+questions',
r'learning\s+goal', r'today\'s\s+goal',
]
narrative = []
for sent in sentences:
sl = sent.lower().strip()
if len(sl.split()) < 5:
continue
if any(re.search(k, sl) for k in instruction_kw):
continue
narrative.append(sent.strip())
# Group into passages of 3+ sentences, 25+ words
fks = []
current = []
for sent in narrative:
current.append(sent)
if len(current) >= 3:
passage = ' '.join(current)
wc = len(re.findall(r'[a-zA-Z]+', passage))
if wc >= 25:
fk = fk_grade(passage)
if fk and 1 <= fk <= 8:
fks.append(fk)
current = []
return fks
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# COURSE CHECKS
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
def check_ela():
"""ELA: phonics accuracy, spelling typos, reading level tracking."""
pdfs = get_student_pdfs(os.path.join(BASE, 'English_Language_Arts'))
issues = []
passage_fks = defaultdict(list)
PHONICS = {
'ai': 'a', 'ay': 'a', 'ee': 'e', 'ea': 'e',
'oa': 'o', 'igh': 'i', 'oi': 'oi', 'oy': 'oi',
}
PHONICS_RE = [
(r'AI\s+(?:makes?|sounds?\s+like|=)\s+(?:long\s+)?([aeiou])', 'ai'),
(r'EE\s+(?:makes?|sounds?\s+like|=)\s+(?:long\s+)?([aeiou])', 'ee'),
(r'EA\s+(?:makes?|sounds?\s+like|=)\s+(?:long\s+)?([aeiou])', 'ea'),
(r'OA\s+(?:makes?|sounds?\s+like|=)\s+(?:long\s+)?([aeiou])', 'oa'),
(r'IGH\s+(?:makes?|sounds?\s+like|=)\s+(?:long\s+)?([aeiou])', 'igh'),
]
SPELL_TYPOS = {
'teh': 'the', 'adn': 'and', 'taht': 'that', 'wiht': 'with',
'recieve': 'receive', 'becuase': 'because',
}
for pdf_path in pdfs:
week, day = extract_week_day(pdf_path)
try:
doc = fitz.open(pdf_path)
full_text = strip_html(''.join(p.get_text() for p in doc))
doc.close()
except Exception:
continue
# Track reading level
fks = get_passage_fks(full_text)
passage_fks[week].extend(fks)
# Phonics accuracy
for pattern, team in PHONICS_RE:
for m in re.finditer(pattern, full_text, re.I):
claimed = m.group(1).lower()
expected = PHONICS.get(team, claimed)
if claimed != expected:
after = full_text[m.end():m.end()+30].strip().lower()
if not re.match(r'^(a|an|the)\s+\w+', after):
issues.append(
f'PHONICS: W{week:02d} {day}: "{m.group(0)}" ā '
f'{team.upper()} = long {expected.upper()}, not {claimed.upper()}'
)
# Spelling typos
words = re.findall(r'\b([a-zA-Z]{4,})\b', full_text)
for w in words:
wl = w.lower()
if wl in SPELL_TYPOS and wl != SPELL_TYPOS[wl]:
issues.append(f'SPELLING: W{week:02d} {day}: "{w}" ā "{SPELL_TYPOS[wl]}"')
# Build FK summary
all_fks = []
fk_by_week = {}
for w in sorted(passage_fks.keys()):
fks = sorted(passage_fks[w])
all_fks.extend(fks)
fk_by_week[f'W{w:02d}'] = {
'avg': round(sum(fks)/len(fks), 1),
'median': round(fks[len(fks)//2], 1),
}
overall = {}
if all_fks:
overall = {
'avg': round(sum(all_fks)/len(all_fks), 1),
'median': round(sorted(all_fks)[len(all_fks)//2], 1),
}
return {
'course': 'ELA', 'pdfs': len(pdfs), 'passages': len(all_fks),
'issues': issues, 'reading_levels': fk_by_week, 'overall_fk': overall,
}
def check_math():
"""Math: verify same-line answer keys (not fill-in-the-blank)."""
pdfs = get_student_pdfs(os.path.join(BASE, 'Math'))
issues = []
week_fks = defaultdict(list)
for pdf_path in pdfs:
week, day = extract_week_day(pdf_path)
try:
doc = fitz.open(pdf_path)
full_text = strip_html(''.join(p.get_text() for p in doc))
doc.close()
except Exception:
continue
# Check same-line equations only
# Student exercises have answers blank (on next line) ā skip cross-line
# Only match lines with exactly ONE +/- operator to avoid repeated addition (3+2+3+2=10)
for line in full_text.split('\n'):
n_ops = len(re.findall(r'[+\-]', line))
if n_ops != 1:
continue
equations = re.findall(r'(\d+)\s*([+\-])\s*(\d+)\s*= *(\d+)', line)
for a, op, b, result in equations:
a, b, result = int(a), int(b), int(result)
expected = a + b if op == '+' else a - b
if result != expected:
issues.append(
f'EQUATION: W{week:02d} {day}: "{a} {op} {b} = {result}" '
f'(should be {expected})'
)
# Track reading level of word problems
fks = get_passage_fks(full_text)
week_fks[week].extend(fks)
all_fks = []
for fks in week_fks.values():
all_fks.extend(fks)
overall = {}
if all_fks:
overall = {'avg': round(sum(all_fks)/len(all_fks), 1)}
return {
'course': 'Math', 'pdfs': len(pdfs), 'passages': len(all_fks),
'issues': issues, 'overall_fk': overall,
}
def check_history():
"""History: anachronism detection, factual red flags, reading level tracking."""
pdfs = get_student_pdfs(os.path.join(BASE, 'History'))
issues = []
passage_fks = defaultdict(list)
ANACHRONISMS = [
('internet', 'caveman'), ('internet', 'ancient egypt'),
('computer', 'pioneer'), ('smartphone', 'colonial'),
('electricity', 'ancient rome'),
]
RED_FLAGS = [
(r'caveman.*\s*dinosa', 'Cavemen/dinosaurs coexistence'),
]
for pdf_path in pdfs:
week, day = extract_week_day(pdf_path)
try:
doc = fitz.open(pdf_path)
full_text = strip_html(''.join(p.get_text() for p in doc))
doc.close()
except Exception:
continue
text_lower = full_text.lower()
# Track reading level
fks = get_passage_fks(full_text)
passage_fks[week].extend(fks)
# Anachronism check
for w1, w2 in ANACHRONISMS:
if w1 in text_lower and w2 in text_lower:
issues.append(f'ANACHRONISM: W{week:02d} {day}: "{w1}" + "{w2}"')
# Red flag patterns
for pattern, desc in RED_FLAGS:
if re.search(pattern, text_lower):
issues.append(f'FACT_CHECK: W{week:02d} {day}: {desc}')
all_fks = []
for fks in passage_fks.values():
all_fks.extend(fks)
overall = {}
if all_fks:
overall = {'avg': round(sum(all_fks)/len(all_fks), 1)}
return {
'course': 'History', 'pdfs': len(pdfs), 'passages': len(all_fks),
'issues': issues, 'overall_fk': overall,
}
def check_science():
"""Science: safety checks, scientific accuracy spot-checks, reading level tracking."""
pdfs = get_student_pdfs(os.path.join(BASE, 'Science'))
issues = []
passage_fks = defaultdict(list)
SAFETY = [
(r'taste\s+(?:unknown|chemical|cleaning)', 'Tasting unknown substances'),
(r'touch\s+hot\s+(?:stove|fire)', 'Touching hot surfaces'),
(r'microwave\s+metal', 'Metal in microwave'),
]
for pdf_path in pdfs:
week, day = extract_week_day(pdf_path)
try:
doc = fitz.open(pdf_path)
full_text = strip_html(''.join(p.get_text() for p in doc))
doc.close()
except Exception:
continue
text_lower = full_text.lower()
# Track reading level
fks = get_passage_fks(full_text)
passage_fks[week].extend(fks)
# Safety check
for pattern, desc in SAFETY:
if re.search(pattern, text_lower):
issues.append(f'SAFETY: W{week:02d} {day}: {desc}')
# Photosynthesis: only flag if text discusses plants making food
if re.search(r'plant.*make.*food|food.*plant.*make', text_lower):
if 'sunlight' not in text_lower and 'sun' not in text_lower:
issues.append(f'SCIENCE: W{week:02d} {day}: "plant making food" without sunlight')
# Water cycle completeness
if 'water cycle' in text_lower:
missing = []
for term in ['evaporat', 'condens', 'precipitat']:
if term not in text_lower:
missing.append(term)
if missing and len(missing) < 3:
issues.append(
f'SCIENCE: W{week:02d} {day}: water cycle incomplete ({", ".join(missing)})'
)
# Earth orbit
if 'earth' in text_lower and 'orbit' in text_lower:
if re.search(r'earth.*orbit.*moon', text_lower):
if 'sun' not in text_lower:
issues.append(f'SCIENCE: W{week:02d} {day}: earth orbit without sun')
all_fks = []
for fks in passage_fks.values():
all_fks.extend(fks)
overall = {}
if all_fks:
overall = {'avg': round(sum(all_fks)/len(all_fks), 1)}
return {
'course': 'Science', 'pdfs': len(pdfs), 'passages': len(all_fks),
'issues': issues, 'overall_fk': overall,
}
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# OUTPUT
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
def print_issues(issues, limit=10):
"""Print issues, truncating if too many."""
for issue in issues[:limit]:
print(f' ā {issue}')
if len(issues) > limit:
print(f' ... and {len(issues) - limit} more')
def main():
parser = argparse.ArgumentParser(description='Curriculum quality verifier')
parser.add_argument('--course', default='all',
help='Course: ela, math, history, science, all')
parser.add_argument('--report', action='store_true',
help='Save JSON report')
args = parser.parse_args()
courses = {
'ela': check_ela,
'math': check_math,
'history': check_history,
'science': check_science,
}
if args.course != 'all':
key = args.course.lower()
if key in courses:
courses = {key: courses[key]}
else:
print(f'Unknown course: {key}. Available: ela, math, history, science, all')
return 1
results = {}
total_issues = 0
for name, check_fn in courses.items():
print(f'\n{"="*55}')
print(f'Checking {name.upper()}...')
print('='*55)
try:
result = check_fn()
results[name] = result
print(f' PDFs: {result["pdfs"]}')
if 'passages' in result:
print(f' Passages: {result["passages"]}')
issue_count = len(result.get('issues', []))
print(f' Issues: {issue_count}')
if result.get('overall_fk'):
fk = result['overall_fk']
print(f' Reading level: avg={fk.get("avg", "?")}, '
f'median={fk.get("median", "?")}')
total_issues += issue_count
if issues_to_show := result.get('issues', []):
print_issues(issues_to_show)
except Exception as e:
print(f' ā ERROR: {e}')
import traceback
traceback.print_exc()
results[name] = {'error': str(e)}
# Summary
print(f'\n{"="*55}')
print('SUMMARY')
print('='*55)
print(f'\n{"Course":<10} {"PDFs":>6} {"Issues":>8} {"Reading":>8}')
print('-'*36)
for name, result in results.items():
pdfs = result.get('pdfs', 0)
n_issues = len(result.get('issues', []))
fk_avg = result.get('overall_fk', {}).get('avg', '-')
print(f'{name.upper():<10} {pdfs:>6} {n_issues:>8} {"avg=" + str(fk_avg):>8}')
print(f'\nTotal issues: {total_issues}')
if total_issues == 0:
print('\nā
All courses pass automated checks.')
else:
# Categorize issues
cats = defaultdict(list)
for result in results.values():
for issue in result.get('issues', []):
cat = issue.split(':')[0]
cats[cat].append(issue)
print('\nBreakdown:')
for cat, cat_issues in sorted(cats.items()):
print(f' {cat}: {len(cat_issues)}')
# Save report
if args.report:
report = {
'timestamp': datetime.now().isoformat(),
'total_issues': total_issues,
'courses': results,
}
path = os.path.join(BASE, 'verification_report.json')
with open(path, 'w') as f:
json.dump(report, f, indent=2)
print(f'\nReport: {path}')
return 1 if total_issues > 0 else 0
if __name__ == '__main__':
sys.exit(main())