#!/usr/bin/env python3
"""
Comprehensive quality audit for all ELA week generators.
Checks performed per file:
1. Python syntax valid (no IndentationError, etc.)
2. Duplicate if __name__ == "__main__" blocks
3. Word swap data: original words actually appear in the sentence
4. Word swap data: new words are different from original words
5. Word swap data: grammatical role compatibility (noun→noun, adj→adj, etc.)
6. Story text quality: length, article agreement (a/an), forced word cramming
7. Teacher guide swap answers match the swap data
"""
import ast
import os
import re
import sys
from pathlib import Path
ELA_ROOT = Path(os.path.expanduser("~/Home_School/2nd_Grade/English_Language_Arts"))
# Part-of-speech hints for checking swap compatibility
NOUN_PATTERNS = re.compile(r'\b(animal|kitten|elephant|octopus|doctor|helmet|bubble|insect|basket|garden|umbrella|winter|cat|dog|fish|bird|tree|house|book|pen|pencil|paper|table|chair|door|window|car|boat|plane|train|bus|bicycle|rabbit|turtle|bear|wolf|fox|mouse|rabbit|sheep|cow|pig|horse|lion|tiger|monkey|penguin|dolphin|whale|seal|frog|snake|spider|butterfly|bee|ant|fly|bee|moth|worm|crab|shrimp|starfish|jellyfish)\b')
VERB_PATTERNS = re.compile(r'\b(run|jump|play|eat|sleep|walk|talk|sing|dance|read|write|draw|paint|swim|fly|climb|jump|hop|skip|roll|throw|catch|kick|hit|pull|push|lift|carry|open|close|start|stop|help|find|see|look|watch|hear|listen|smell|touch|feel|think|know|like|love|want|need|have|make|give|take|put|get|go|come|sit|stand|lie|fall|break|fix|build|create|change|grow|shrink|begin|end|finish|continue|try|work|move|turn|move)\b')
def extract_swap_data(filepath, source):
"""Extract all swap_data blocks from a file.
Returns list of (line_no, sentence, original_words, new_words, data_type)
"""
results = []
# Strategy 1: Look for assignment to swap_data / SWAP_DATA variables
# Pattern: TUESDAY_SWAP_DATA = [ ... ]
# Pattern: swap_data = [ ... ]
in_block = False
block_start = 0
block_lines = []
block_name = ""
bracket_depth = 0
for i, line in enumerate(source.split('\n'), 1):
stripped = line.strip()
# Detect start of swap data block
if not in_block:
# Check for assignment patterns
match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*\[', stripped)
if match and 'swap' in match.group(1).lower():
in_block = True
block_start = i
block_name = match.group(1)
block_lines = [line]
bracket_depth = 1
# Check if block ends on same line
rest = line[match.end():]
bracket_depth += rest.count('[') - rest.count(']')
if bracket_depth <= 0:
in_block = False
results.append(parse_swap_block(block_name, block_lines, block_start))
block_lines = []
else:
block_lines.append(line)
bracket_depth += line.count('[') - line.count(']')
if bracket_depth <= 0:
in_block = False
results.append(parse_swap_block(block_name, block_lines, block_start))
block_lines = []
return results
def parse_swap_block(name, lines, start_line):
"""Parse a swap data block into structured data."""
results = []
# Join all lines and try to eval as Python
full_text = '\n'.join(lines)
# Try to extract the list literal
try:
tree = ast.parse(full_text, mode='eval')
if isinstance(tree.body, ast.List):
for idx, item in enumerate(tree.body.elts):
line_no = start_line + idx
if isinstance(item, ast.Tuple):
try:
sentence = ast.literal_eval(item.elts[0])
except:
sentence = None
new_words = []
orig_words = []
# Different swap data formats:
# Format A: (sentence, (orig1, new1), (orig2, new2))
# Format B: (sentence, orig_word, new_word)
if len(item.elts) >= 3:
for el in item.elts[1:]:
if isinstance(el, ast.Tuple) and len(el) == 2:
try:
orig, new = ast.literal_eval(el.elts[0]), ast.literal_eval(el.elts[1])
orig_words.append(orig)
new_words.append(new)
except:
pass
elif isinstance(el, ast.Constant) or (hasattr(ast, 'Str') and isinstance(el, ast.Str)):
try:
word = ast.literal_eval(el)
if isinstance(word, str):
# Could be orig_word or new_word depending on context
if len(orig_words) == len(new_words):
orig_words.append(word)
elif len(orig_words) == len(new_words) + 1:
new_words.append(word)
except:
pass
results.append({
'line': line_no,
'sentence': sentence,
'orig_words': orig_words,
'new_words': new_words,
'block_name': name,
})
except:
pass
return results
def check_swap_issues(swap_item):
"""Check a single swap item for quality issues."""
issues = []
sentence = swap_item['sentence']
orig_words = swap_item['orig_words']
new_words = swap_item['new_words']
line = swap_item['line']
if not sentence:
issues.append((line, "HIGH", "Empty or unreadable sentence in swap data"))
return issues
# Strip HTML tags for analysis
plain = re.sub(r'<[^>]+>', '', sentence)
sentence_words = set(re.findall(r'\b(\w+)\b', plain))
for i, orig in enumerate(orig_words):
if orig.lower() not in sentence_words:
issues.append((line, "HIGH", f"Original word '{orig}' not found in sentence: {plain[:60]}..."))
for i, new in enumerate(new_words):
if i < len(orig_words):
if new.lower() == orig_words[i].lower():
issues.append((line, "HIGH", f"Swap word equals original: '{orig_words[i]}' → '{new}' (no change)"))
# Check if new words are already in the sentence (different from what they replace)
for i, new in enumerate(new_words):
if i < len(orig_words):
orig = orig_words[i]
if new.lower() != orig.lower() and new.lower() in sentence_words:
issues.append((line, "MEDIUM", f"New word '{new}' already appears in sentence (confusing)"))
# Check grammatical role compatibility
for i, (orig, new) in enumerate(zip(orig_words, new_words)):
orig_is_noun = bool(NOUN_PATTERNS.search(orig.lower()))
new_is_noun = bool(NOUN_PATTERNS.search(new.lower()))
orig_is_verb = bool(VERB_PATTERNS.search(orig.lower()))
new_is_verb = bool(VERB_PATTERNS.search(new.lower()))
if orig_is_noun and not new_is_noun and not new_is_verb:
# Check if it makes sense grammatically
pass # Hard to check without full grammar, skip for now
return issues
def check_story_text(filepath, source):
"""Check story text for quality issues."""
issues = []
# Find long strings that look like stories (contain <strong> tags)
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
text = node.value
if '<strong>' in text and len(text) > 80:
plain = re.sub(r'<[^>]+>', '', text)
words = re.findall(r'\b(\w+)\b', plain)
word_count = len(words)
# Check article agreement
if re.search(r'\b[aA]\s+([aeiou])\w+', text):
for m in re.finditer(r'\b([aA])\s+(\w+)', plain):
article, word = m.group(1), m.group(2)
if word[0].lower() in 'aeiou' and article.lower() == 'a':
issues.append((node.lineno, "MEDIUM", f"Article error: '{article} {word}' should be 'an {word}'"))
# Check for "an" before consonant
if re.search(r'\b[aA][nN]\s+([bcdfghjklmnpqrstvwxyz])\w+', plain):
for m in re.finditer(r'\b([aA][nN])\s+(\w+)', plain):
article, word = m.group(1), m.group(2)
if word[0].lower() not in 'aeiou' and article.lower() == 'an':
issues.append((node.lineno, "MEDIUM", f"Article error: '{article} {word}' should be 'a {word}'"))
# Check for nonsensical phrases
# "bubble" + "insect" adjacent
if re.search(r'\b(bubble|insect)\s+(bubble|insect)\b', plain):
issues.append((node.lineno, "MEDIUM", f"Possible repetitive/forced word usage in story"))
# "a insect" / "a octopus"
if re.search(r'\b[aA]\s+insect\b', plain):
issues.append((node.lineno, "HIGH", f"'a insect' should be 'an insect'"))
if re.search(r'\b[aA]\s+octopus\b', plain):
issues.append((node.lineno, "HIGH", f"'a octopus' should be 'an octopus'"))
# Story too short for reading comprehension
if word_count < 40 and '<strong>' in text:
issues.append((node.lineno, "MEDIUM", f"Story only {word_count} words - may be too short for reading comprehension"))
# Story too long
if word_count > 350:
issues.append((node.lineno, "LOW", f"Story is {word_count} words - may be long for 2nd grade"))
return issues
def check_syntax(filepath, source):
"""Check for syntax issues."""
issues = []
# Duplicate if __name__
count = source.count('if __name__ == "__main__":')
if count > 1:
issues.append((0, "HIGH", f"Duplicate if __name__ == '__main__' blocks ({count} found)"))
# Try to parse
try:
ast.parse(source)
except SyntaxError as e:
issues.append((e.lineno or 0, "HIGH", f"Syntax error: {e.msg}"))
return issues
def check_teacher_answers(filepath, source):
"""Check that teacher guide swap answers actually correspond to the swap data."""
issues = []
# Look for TEACHER_*_SWAP_ANSWERS and compare with TUESDAY/THURSDAY_SWAP_DATA
# This is a basic check - ensure the count matches
swap_data_blocks = re.findall(r'(?:TUESDAY|THURSDAY)_SWAP_DATA\s*=\s*\[', source)
teacher_answer_blocks = re.findall(r'TEACHER_(?:TUESDAY|THURSDAY)_SWAP_ANSWERS\s*=\s*\[', source)
# Count entries in each
tuesday_swap_count = 0
thursday_swap_count = 0
tuesday_teacher_count = 0
thursday_teacher_count = 0
# Extract and count list items (simple heuristic)
tuesday_match = re.search(r'TUESDAY_SWAP_DATA\s*=\s*\[(.*?)\]', source, re.DOTALL)
if tuesday_match:
tuesday_swap_count = len(re.findall(r'\(', tuesday_match.group(1)))
thursday_match = re.search(r'THURSDAY_SWAP_DATA\s*=\s*\[(.*?)\]', source, re.DOTALL)
if thursday_match:
thursday_swap_count = len(re.findall(r'\(', thursday_match.group(1)))
tuesday_t_match = re.search(r'TEACHER_TUESDAY_SWAP_ANSWERS\s*=\s*\[(.*?)\]', source, re.DOTALL)
if tuesday_t_match:
tuesday_teacher_count = len(re.findall(r'\(', tuesday_t_match.group(1)))
thursday_t_match = re.search(r'TEACHER_THURSDAY_SWAP_ANSWERS\s*=\s*\[(.*?)\]', source, re.DOTALL)
if thursday_t_match:
thursday_teacher_count = len(re.findall(r'\(', thursday_t_match.group(1)))
if tuesday_swap_count > 0 and tuesday_teacher_count > 0 and tuesday_swap_count != tuesday_teacher_count:
issues.append((0, "MEDIUM", f"Teacher Tuesday swap answers ({tuesday_teacher_count}) don't match swap data ({tuesday_swap_count})"))
if thursday_swap_count > 0 and thursday_teacher_count > 0 and thursday_swap_count != thursday_teacher_count:
issues.append((0, "MEDIUM", f"Teacher Thursday swap answers ({thursday_teacher_count}) don't match swap data ({thursday_swap_count})"))
return issues
def audit_file(filepath):
"""Run all audits on a single file. Returns list of (line, severity, msg)."""
rel = str(filepath.relative_to(ELA_ROOT))
all_issues = []
try:
source = filepath.read_text()
except Exception as e:
return [(0, "ERROR", f"Cannot read file: {e}")]
# Syntax check
all_issues.extend(check_syntax(filepath, source))
if any(s == "HIGH" for _, s, _ in all_issues):
return all_issues # Skip deeper checks if syntax broken
# Swap data checks
swap_blocks = extract_swap_data(filepath, source)
for block in swap_blocks:
for item in block:
all_issues.extend(check_swap_issues(item))
# Story quality checks
all_issues.extend(check_story_text(filepath, source))
# Teacher answer checks
all_issues.extend(check_teacher_answers(filepath, source))
return all_issues
def main():
print("=" * 80)
print("ELA CURRICULUM QUALITY AUDIT")
print("=" * 80)
# Find all week generators
generators = []
for week_dir in sorted(ELA_ROOT.glob("Week_*")):
for f in week_dir.glob("generate_week*.py"):
if "CORRUPTED" not in f.name and "backup" not in f.name and "_new" not in f.name:
generators.append(f)
print(f"\nScanning {len(generators)} week generators...\n")
total_issues = 0
high_count = 0
medium_count = 0
low_count = 0
for filepath in generators:
issues = audit_file(filepath)
if issues:
rel = filepath.relative_to(ELA_ROOT)
print(f"📄 {rel}")
for line, severity, msg in issues:
if severity == "HIGH":
icon = "🔴"
high_count += 1
elif severity == "MEDIUM":
icon = "🟡"
medium_count += 1
elif severity == "LOW":
icon = "🔵"
low_count += 1
else:
icon = "⚫"
total_issues += 1
print(f" {icon} Line {line}: [{severity}] {msg}")
print()
print("=" * 80)
print(f"AUDIT SUMMARY: {total_issues} issues found")
print(f" 🔴 HIGH: {high_count}")
print(f" 🟡 MEDIUM: {medium_count}")
print(f" 🔵 LOW: {low_count}")
print("=" * 80)
if __name__ == "__main__":
main()