brain backup 2026-09-14

This commit is contained in:
Adolfo Reyna
2026-09-14 22:38:47 -04:00
parent ca346cc6e7
commit f0ead4f6fa
147 changed files with 73451 additions and 8 deletions
+29
View File
@@ -0,0 +1,29 @@
# Grade 3 Learning Studio Demo
## Start it tomorrow
```bash
cd ~/brain/projects/ckmath-interactive-pilot
python3 server.py --port 8899
```
Open `http://127.0.0.1:8899` on the Mac mini. This is intentionally loopback-only: it is a private demo, not a LAN service.
## A 3-minute walkthrough for Alicia
1. **Start at the landing page.** Explain that the library is derived from the downloaded Grade 3 curriculum, not a separate subscription or AI-generated scope-and-sequence.
2. **Use a subject filter.** Choose **Math** to show the nine Grade 3 math entries, then point out the “Open Teacher Guide” and “Open Student Reader” links.
3. **Open CKMath Unit 1’s Teacher Guide.** It opens the original 285-page guide from inside the verified ZIP—no duplicate PDF copy is created. Explain that this is where the teacher’s learning goals, timing, prompts, materials, expected responses, and support notes live.
4. **Return to the site and open the Interactive lesson.** Demonstrate building seven pairs of shoes, writing `7 × 2 = 14`, and giving a one-sentence explanation.
5. **Frame the next decision.** The library and teacher-plan access can cover the whole Grade 3 collection now; we should next choose which curriculum areas deserve human-reviewed interactive sessions first.
## Current scope
- 51 downloaded Grade 3 units indexed.
- 37 teacher-guide PDFs and 25 student-resource PDFs available on demand from their original archives.
- One completed interactive math lesson: Unit 1, equal groups.
- No account, analytics, AI, or learner-record storage.
## Not yet claimed
This is not yet a complete digitization of every Grade 3 lesson. It is a full source library plus a working interactive pattern. Every additional interactive lesson should be selected, authored/reviewed against its teacher guide, and checked for its individual license notice.
@@ -0,0 +1,24 @@
# Grade 3 Learning Studio
A private, browser-only Grade 3 curriculum showcase built from the verified local Core Knowledge collection. It catalogs every downloaded Grade 3 archive, exposes original teacher-guide/student PDFs without duplicating them, and includes a 10-minute interactive equal-groups math session.
## Run locally
```bash
cd ~/brain/projects/ckmath-interactive-pilot
python3 server.py --port 8899
```
Open `http://127.0.0.1:8899`. The server is deliberately loopback-only and does not expose the curriculum on the home network.
See `DEMO.md` for Alicia’s short walkthrough.
## Privacy
This showcase uses no login, AI service, analytics, or learner-data storage. The original curriculum assets remain inside `/Users/adolforeyna/Downloads/CoreKnowledge_Grades_1-5_EN_ES`; the server streams a selected original PDF only after a locally generated, catalog-known material ID is requested.
## Source and license
The catalog is derived from the verified downloaded Core Knowledge Grade 3 archive set. The interactive session is adapted from *CKMath Grade 3, Unit 1: Introducing Multiplication*, Core Knowledge Foundation, Lesson 9 (“Equal Group Situations”), downloaded from [Core Knowledge](https://www.coreknowledge.org/download-free-curriculum/).
The sampled math source PDF carries a Creative Commons **Attribution–NonCommercial–ShareAlike 4.0 International** notice. This pilot is private and non-commercial. Any future sharing must retain attribution, the license notice, and the same ShareAlike terms. Confirm the license embedded in every additional unit before adapting it.
+81
View File
@@ -0,0 +1,81 @@
import { isCorrectTotal, isCorrectEquation, hasReasoningEvidence } from './src/session.mjs';
const groups = document.querySelector('#groups');
const addPair = document.querySelector('#add-pair');
const buildFeedback = document.querySelector('#build-feedback');
const totalRow = document.querySelector('#total-row');
const total = document.querySelector('#total');
const totalFeedback = document.querySelector('#total-feedback');
const activity2 = document.querySelector('#activity-2');
const activity3 = document.querySelector('#activity-3');
const equation = document.querySelector('#equation');
const equationFeedback = document.querySelector('#equation-feedback');
const reasoning = document.querySelector('#reasoning');
const reasoningFeedback = document.querySelector('#reasoning-feedback');
let pairsAdded = 0;
function feedback(element, message, kind = '') {
element.textContent = message;
element.className = `feedback ${kind}`;
}
function renderGroups() {
groups.innerHTML = '';
for (let i = 1; i <= 7; i += 1) {
const group = document.createElement('div');
group.className = 'group';
group.innerHTML = `<span>Person ${i}</span><div class="shoes">${i <= pairsAdded ? '👟 👟' : '· ·'}</div>`;
groups.append(group);
}
}
function reveal(activity, progressId) {
activity.hidden = false;
document.querySelector(progressId).classList.add('active');
activity.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
renderGroups();
addPair.addEventListener('click', () => {
if (pairsAdded < 7) pairsAdded += 1;
renderGroups();
if (pairsAdded === 7) {
addPair.disabled = true;
addPair.textContent = 'All 7 pairs are built';
feedback(buildFeedback, 'Nice work. You built 7 equal groups of 2 shoes.', 'good');
totalRow.hidden = false;
total.focus();
} else {
feedback(buildFeedback, `${pairsAdded} of 7 pairs built. Keep going.`);
}
});
document.querySelector('#check-total').addEventListener('click', () => {
if (isCorrectTotal(total.value)) {
feedback(totalFeedback, 'Yes—7 groups of 2 make 14 shoes.', 'good');
reveal(activity2, '#progress-2');
equation.focus();
} else {
feedback(totalFeedback, 'Try counting by twos: 2, 4, 6…', 'bad');
}
});
document.querySelector('#check-equation').addEventListener('click', () => {
if (isCorrectEquation(equation.value)) {
feedback(equationFeedback, 'Correct. Multiplication is a quick way to name equal groups.', 'good');
reveal(activity3, '#progress-3');
reasoning.focus();
} else {
feedback(equationFeedback, 'Use the numbers 7, 2, and 14. Either order for the factors works.', 'bad');
}
});
document.querySelector('#check-reasoning').addEventListener('click', () => {
if (hasReasoningEvidence(reasoning.value)) {
feedback(reasoningFeedback, 'Session complete! You used equal groups and multiplication to explain your answer.', 'good');
} else {
feedback(reasoningFeedback, 'Add how many groups there were, how many were in each group, and the total of 14.', 'bad');
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
# Grade 3 Curriculum Showcase Implementation Plan
> **For Hermes:** Build the showable local demo task-by-task with test-first checks.
**Goal:** Turn the downloaded Grade 3 Core Knowledge collection into a private local library that exposes every available unit, its student resources, and its teacher-guide PDFs, while keeping the interactive equal-groups session as a concrete lesson example for Alicia.
**Architecture:** A local Python server serves the existing static interactive session and a generated Grade 3 catalog. The catalog is derived directly from the verified download manifest and ZIP contents; teacher/student PDFs stay inside their original ZIP archives and are streamed only on demand, avoiding copied curriculum files. The interface uses browser-only state and no accounts or learner-data storage.
**Tech stack:** Python stdlib (`http.server`, `zipfile`, `json`), PyMuPDF for PDF page metadata, vanilla HTML/CSS/JavaScript, Node’s built-in test runner.
---
### Task 1: Derive a Grade 3 catalog from verified downloads
- Add a failing test for grade filtering, subject classification, and teacher-guide detection.
- Implement `src/catalog.mjs` pure helpers.
- Implement `scripts/build_grade3_catalog.py` to inspect every Grade 3 archive from `download-manifest.json`, recording unit title, source archive, internal PDFs, material roles, and PDF page counts.
- Generate `data/grade3_catalog.json`.
### Task 2: Stream original PDFs without copying curriculum
- Add a failing server test for a manifest-known PDF route and an unknown/traversal-style route.
- Implement `server.py` with static-file serving, a catalog endpoint, and a constrained ZIP-PDF route based only on IDs present in `data/grade3_catalog.json`.
### Task 3: Add a Grade 3 library and teacher-plan view
- Add a failing browser-independent test for filtering by subject and selection state.
- Extend the local UI with a Grade 3 library showing every catalogued unit, student resources, teacher guides, source/licensing notice, and one selected teacher-plan preview.
- Link teacher guides to the constrained streaming route and retain the first interactive math session as an explicit classroom activity.
### Task 4: Verify the demo
- Run catalog generation, all tests, server route checks, browser learner flow, and accessibility audit.
- Demonstrate responsive library browsing and a teacher guide opening in the browser.
- Stop the temporary local server after the demonstration; do not bind to the LAN or store learner data.
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="A private local Grade 3 Core Knowledge curriculum library and interactive lesson pilot.">
<title>Grade 3 Learning Studio</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="#top" aria-label="Grade 3 Learning Studio home">Grade 3 <span>Learning Studio</span></a>
<nav aria-label="Primary navigation"><a href="#library">Library</a><a href="#interactive-session">Interactive lesson</a></nav>
</header>
<main id="top">
<section class="hero" aria-labelledby="hero-title">
<div>
<p class="eyebrow">Private family curriculum pilot</p>
<h1 id="hero-title">A whole Grade 3 year—organized around real teaching.</h1>
<p class="lede">Browse the downloaded Core Knowledge units, open their original teacher plans and student materials, then use interactive sessions for the moments where practice benefits from response and feedback.</p>
<div class="hero-actions"><a class="button" href="#library">Explore Grade 3</a><a class="button quiet" href="#interactive-session">Try the math session</a></div>
</div>
<aside class="hero-note" aria-label="What this demo includes">
<p class="note-label">Tonight’s working demo</p>
<p><strong id="unit-count">…</strong> Grade 3 units indexed</p>
<p><strong id="guide-count">…</strong> teacher-guide PDFs ready to open</p>
<p><strong id="student-count">…</strong> student resources indexed</p>
<hr>
<p class="small">No account, AI, analytics, or learner records. The source files stay in the verified local curriculum archive.</p>
</aside>
</section>
<section class="teacher-bridge" aria-labelledby="teacher-bridge-title">
<div><p class="eyebrow">Teacher-led by design</p><h2 id="teacher-bridge-title">The lesson plan stays in charge.</h2></div>
<p>Each unit keeps its original teacher guide—learning goals, materials, talk moves, expected responses, and support for misunderstandings. Interactive activities are a layer on top, not a replacement for the teacher.</p>
</section>
<section id="library" class="library-section" aria-labelledby="library-title">
<div class="section-head"><div><p class="eyebrow">The downloaded collection</p><h2 id="library-title">Grade 3 curriculum library</h2></div><p id="library-status" class="small" aria-live="polite">Loading the verified local catalog…</p></div>
<div id="subject-filters" class="filters" role="group" aria-label="Filter units by subject"></div>
<div id="unit-grid" class="unit-grid"></div>
</section>
<section id="interactive-session" class="interactive-wrap" aria-labelledby="interactive-title">
<div class="section-head"><div><p class="eyebrow">Interactive lesson example</p><h2 id="interactive-title">Math · Unit 1 · Equal groups</h2></div><p class="small">Based on the Unit 1 teacher-plan activity: represent equal-group situations with concrete objects or drawings.</p></div>
<div class="progress" role="group" aria-label="Session progress"><span class="active" id="progress-1"></span><span id="progress-2"></span><span id="progress-3"></span></div>
<section class="session" id="activity-1" aria-labelledby="activity-1-title">
<h3 id="activity-1-title">1. Build the situation</h3>
<p class="prompt">There are <strong>7 people</strong> wearing shoes. Each person is wearing <strong>2 shoes</strong>. Build the equal groups.</p>
<div class="groups" id="groups" role="group" aria-label="Seven groups of shoes"></div>
<button id="add-pair" type="button">Add a pair of shoes</button>
<p class="feedback" id="build-feedback" aria-live="polite">Add one pair for each person.</p>
<div class="answer-row" id="total-row" hidden><label for="total">How many shoes altogether?</label><input id="total" inputmode="numeric" autocomplete="off" aria-describedby="total-help"><button id="check-total" type="button">Check total</button></div>
<p class="tip" id="total-help">Count by twos or add 2 seven times.</p><p class="feedback" id="total-feedback" aria-live="polite"></p>
</section>
<section class="session" id="activity-2" aria-labelledby="activity-2-title" hidden>
<h3 id="activity-2-title">2. Name it with multiplication</h3><p class="prompt">Write an equation for the groups you built. You can use <strong>×</strong> or <strong>x</strong>.</p>
<div class="answer-row"><label for="equation">Equation</label><input id="equation" autocomplete="off" placeholder="7 × 2 = 14"><button id="check-equation" type="button">Check equation</button></div><p class="feedback" id="equation-feedback" aria-live="polite"></p><p class="tip">Both 7 × 2 = 14 and 2 × 7 = 14 describe the same total.</p>
</section>
<section class="session" id="activity-3" aria-labelledby="activity-3-title" hidden>
<h3 id="activity-3-title">3. Show your thinking</h3><p class="prompt">How did the groups help you know there are 14 shoes?</p><label for="reasoning">Your explanation</label><textarea id="reasoning" placeholder="I saw 7 groups of 2. I added…"></textarea><div class="answer-row"><button id="check-reasoning" type="button">Finish session</button></div><p class="feedback" id="reasoning-feedback" aria-live="polite"></p>
</section>
</section>
<p class="source">Curriculum source: Core Knowledge Foundation, downloaded from <a href="https://www.coreknowledge.org/download-free-curriculum/">Core Knowledge</a>. This private, non-commercial pilot links to the original local curriculum materials and retains each source unit’s embedded license and attribution notice. Confirm the embedded license before adapting or sharing any additional unit.</p>
</main>
<script type="module" src="app.mjs"></script>
<script type="module" src="library-app.mjs"></script>
</body>
</html>
@@ -0,0 +1,49 @@
import { filterUnits, subjectsFor, teacherGuidesFor } from './src/library.mjs';
const filters = document.querySelector('#subject-filters');
const grid = document.querySelector('#unit-grid');
const status = document.querySelector('#library-status');
let units = [];
let selectedSubject = 'All';
function resourceLink(material) {
return `<a class="resource-link" href="/api/pdf/${material.id}" target="_blank" rel="noopener">Open ${material.role}<span aria-hidden="true"> ↗</span></a>`;
}
function renderFilters() {
filters.innerHTML = subjectsFor(units).map((subject) => `<button class="filter ${subject === selectedSubject ? 'selected' : ''}" type="button" data-subject="${subject}">${subject}</button>`).join('');
filters.querySelectorAll('button').forEach((button) => button.addEventListener('click', () => {
selectedSubject = button.dataset.subject;
renderFilters();
renderUnits();
}));
}
function renderUnits() {
const filtered = filterUnits(units, selectedSubject);
status.textContent = `${filtered.length} ${selectedSubject === 'All' ? 'units' : selectedSubject + ' units'} shown`;
grid.innerHTML = filtered.map((unit) => {
const guides = teacherGuidesFor(unit);
const student = unit.materials.filter((material) => material.role === 'Student Reader');
const resources = [...guides, ...student].slice(0, 3).map(resourceLink).join('');
return `<article class="unit-card"><p class="unit-subject">${unit.subject}</p><h3>${unit.title.replace(/^Grade 3\s*/, '')}</h3><p class="material-count">${guides.length ? `${guides.length} teacher plan${guides.length === 1 ? '' : 's'}` : 'Teacher plan not identified'} · ${student.length} student resource${student.length === 1 ? '' : 's'}</p>${resources ? `<div class="resource-links">${resources}</div>` : '<p class="small">Open the original archive source for supporting materials.</p>'}<a class="source-link" href="${unit.source_url}" target="_blank" rel="noopener">Core Knowledge source ↗</a></article>`;
}).join('');
}
async function loadLibrary() {
try {
const response = await fetch('/api/catalog', { cache: 'no-store' });
if (!response.ok) throw new Error('catalog unavailable');
const catalog = await response.json();
units = catalog.units;
document.querySelector('#unit-count').textContent = catalog.unit_count;
document.querySelector('#guide-count').textContent = catalog.teacher_guide_count;
document.querySelector('#student-count').textContent = catalog.student_material_count;
renderFilters();
renderUnits();
} catch {
status.textContent = 'The local catalog could not load. Start the private Grade 3 demo server.';
}
}
loadLibrary();
@@ -0,0 +1,9 @@
{
"name": "ckmath-interactive-pilot",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "node --test"
}
}
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Build a local-only Grade 3 catalog from verified Core Knowledge archives."""
import argparse
import hashlib
import json
import re
import zipfile
from pathlib import Path
ROLE_ORDER = {'Student Reader': 0, 'Teacher Guide': 1, 'Reference': 2}
def is_grade_three_source(item):
title = str(item.get('title', ''))
filename = str(item.get('filename', ''))
return bool(re.search(r'\bGrade 3\b', title, re.I) or re.search(r'(?:^|[_-])G3(?:[_-]|$)', filename, re.I))
def classify_unit(filename):
if re.search(r'CKMath', filename, re.I):
return 'Math'
if re.search(r'CKSci', filename, re.I):
return 'Science'
if re.search(r'CKLA', filename, re.I):
return 'Language Arts'
if re.search(r'CKHG|CKIYS|LABB_CKHG', filename, re.I):
return 'History & Geography'
if re.search(r'CKMusic|CKVA', filename, re.I):
return 'Arts'
return 'Other'
def role_for_pdf(filename):
if re.search(r'(?:^|[_-])(TG|TeacherGuide|Teacher[_ -]?Guide)(?:[_-]|\.)', filename, re.I):
return 'Teacher Guide'
if re.search(r'(?:^|[_-])(SR|StudentReader|Student[_ -]?Reader|ActivityBook)(?:[_-]|\.)', filename, re.I):
return 'Student Reader'
return 'Reference'
def material_id(archive_name, internal_path):
raw = '%s\0%s' % (archive_name, internal_path)
return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:20]
def pdf_page_count(archive_path, internal_path):
try:
import fitz
with zipfile.ZipFile(archive_path) as archive:
document = fitz.open(stream=archive.read(internal_path), filetype='pdf')
count = document.page_count
document.close()
return count
except Exception:
return None
def inspect_archive(archive_path, include_page_counts=False):
materials = []
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
internal_path = info.filename
if internal_path.startswith('__MACOSX/') or not internal_path.lower().endswith('.pdf'):
continue
filename = Path(internal_path).name
material = {
'id': material_id(archive_path.name, internal_path),
'filename': filename,
'internal_path': internal_path,
'role': role_for_pdf(filename),
}
if include_page_counts:
material['pages'] = pdf_page_count(archive_path, internal_path)
materials.append(material)
materials.sort(key=lambda item: (ROLE_ORDER[item['role']], item['filename'].lower()))
return materials
def build_catalog(manifest_path, downloads_dir, include_page_counts=False):
entries = json.loads(Path(manifest_path).read_text())
units = []
for item in entries:
if item.get('type') != 'zip' or not is_grade_three_source(item):
continue
archive_path = Path(downloads_dir) / item['filename']
if not archive_path.is_file():
continue
materials = inspect_archive(archive_path, include_page_counts)
units.append({
'id': hashlib.sha256(item['filename'].encode('utf-8')).hexdigest()[:16],
'title': item['title'],
'filename': item['filename'],
'source_url': item['source'],
'subject': classify_unit(item['filename']),
'materials': materials,
'teacher_guide_count': sum(material['role'] == 'Teacher Guide' for material in materials),
'student_material_count': sum(material['role'] == 'Student Reader' for material in materials),
})
units.sort(key=lambda item: (item['subject'], item['title'].lower()))
return {
'grade': 3,
'unit_count': len(units),
'teacher_guide_count': sum(unit['teacher_guide_count'] for unit in units),
'student_material_count': sum(unit['student_material_count'] for unit in units),
'units': units,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--manifest', type=Path, required=True)
parser.add_argument('--downloads-dir', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--page-counts', action='store_true')
args = parser.parse_args()
catalog = build_catalog(args.manifest, args.downloads_dir, args.page_counts)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(catalog, indent=2) + '\n')
print(json.dumps({
'output': str(args.output),
'units': catalog['unit_count'],
'teacher_guides': catalog['teacher_guide_count'],
'student_materials': catalog['student_material_count'],
}))
if __name__ == '__main__':
main()
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Private loopback demo server for the Grade 3 curriculum showcase."""
import argparse
import json
import mimetypes
import os
import re
import sys
import zipfile
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
ROOT = Path(__file__).resolve().parent
DEFAULT_DOWNLOADS = Path.home() / 'Downloads' / 'CoreKnowledge_Grades_1-5_EN_ES'
sys.path.insert(0, str(ROOT / 'src'))
from server_catalog import lookup_material, material_index
class ShowcaseHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, downloads_dir=None, **kwargs):
self.downloads_dir = Path(downloads_dir or DEFAULT_DOWNLOADS).resolve()
super().__init__(*args, directory=str(ROOT), **kwargs)
def catalog(self):
return json.loads((ROOT / 'data' / 'grade3_catalog.json').read_text())
def send_json(self, data):
body = json.dumps(data).encode('utf-8')
self.send_response(HTTPStatus.OK)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(body)
def send_pdf(self, material_id):
if not re.fullmatch(r'[0-9a-f]{20}', material_id):
self.send_error(HTTPStatus.NOT_FOUND)
return
pair = lookup_material(material_index(self.catalog()), material_id)
if pair is None:
self.send_error(HTTPStatus.NOT_FOUND)
return
archive_name, internal_path = pair
archive_path = (self.downloads_dir / archive_name).resolve()
if archive_path.parent != self.downloads_dir:
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
with zipfile.ZipFile(archive_path) as archive:
content = archive.read(internal_path)
except (FileNotFoundError, KeyError, zipfile.BadZipFile):
self.send_error(HTTPStatus.NOT_FOUND)
return
filename = Path(internal_path).name
self.send_response(HTTPStatus.OK)
self.send_header('Content-Type', mimetypes.guess_type(filename)[0] or 'application/pdf')
self.send_header('Content-Length', str(len(content)))
self.send_header('Content-Disposition', 'inline; filename="%s"' % filename.replace('"', ''))
self.send_header('Cache-Control', 'private, no-store')
self.end_headers()
self.wfile.write(content)
def do_GET(self):
path = urlparse(self.path).path
if path == '/api/catalog':
self.send_json(self.catalog())
return
if path.startswith('/api/pdf/'):
self.send_pdf(unquote(path[len('/api/pdf/'):]))
return
super().do_GET()
def run(port, downloads_dir):
def handler(*args, **kwargs):
return ShowcaseHandler(*args, downloads_dir=downloads_dir, **kwargs)
server = ThreadingHTTPServer(('127.0.0.1', port), handler)
print('Serving Grade 3 showcase at http://127.0.0.1:%s' % port)
server.serve_forever()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=8899)
parser.add_argument('--downloads-dir', type=Path, default=DEFAULT_DOWNLOADS)
args = parser.parse_args()
run(args.port, args.downloads_dir)
@@ -0,0 +1,20 @@
export function isGradeThreeSource(source) {
return /\bGrade 3\b/i.test(source.title ?? '') || /(?:^|[_-])G3(?:[_-]|$)/i.test(source.filename ?? '');
}
export function classifyUnit(filename) {
const name = String(filename);
if (/CKMath/i.test(name)) return 'Math';
if (/CKSci/i.test(name)) return 'Science';
if (/CKLA/i.test(name)) return 'Language Arts';
if (/CKHG|CKIYS|LABB_CKHG/i.test(name)) return 'History & Geography';
if (/CKMusic|CKVA/i.test(name)) return 'Arts';
return 'Other';
}
export function roleForPdf(filename) {
const name = String(filename);
if (/(?:^|[_-])(TG|TeacherGuide|Teacher[_ -]?Guide)(?:[_-]|\.)/i.test(name)) return 'Teacher Guide';
if (/(?:^|[_-])(SR|StudentReader|Student[_ -]?Reader|ActivityBook)(?:[_-]|\.)/i.test(name)) return 'Student Reader';
return 'Reference';
}
@@ -0,0 +1,15 @@
export function subjectsFor(units) {
return ['All', ...new Set(units.map((unit) => unit.subject).filter(Boolean))].sort((a, b) => {
if (a === 'All') return -1;
if (b === 'All') return 1;
return a.localeCompare(b);
});
}
export function filterUnits(units, subject) {
return subject === 'All' ? units : units.filter((unit) => unit.subject === subject);
}
export function teacherGuidesFor(unit) {
return (unit.materials ?? []).filter((material) => material.role === 'Teacher Guide');
}
@@ -0,0 +1,18 @@
def material_index(catalog):
"""Map only generated catalog IDs to their exact archive member paths."""
index = {}
for unit in catalog.get('units', []):
archive_name = unit.get('filename')
if not archive_name:
continue
for material in unit.get('materials', []):
material_id = material.get('id')
internal_path = material.get('internal_path')
if material_id and internal_path:
index[material_id] = (archive_name, internal_path)
return index
def lookup_material(index, material_id):
"""Return a declared pair or None; request input is never a filesystem path."""
return index.get(material_id)
@@ -0,0 +1,16 @@
export function isCorrectTotal(value) {
return Number(String(value).trim()) === 14;
}
export function isCorrectEquation(value) {
const numbers = String(value).match(/\d+/g)?.map(Number) ?? [];
if (numbers.length !== 3) return false;
const [first, second, product] = numbers;
return product === 14 && ((first === 7 && second === 2) || (first === 2 && second === 7));
}
export function hasReasoningEvidence(value) {
const text = String(value).toLowerCase();
const strategy = /group|each|repeated|add|plus/.test(text);
return strategy && /\b14\b/.test(text);
}
@@ -0,0 +1,9 @@
:root { --ink:#142737; --muted:#576a75; --paper:#f7f4ed; --card:#fffdfa; --line:#ded7c7; --blue:#156d92; --blue-deep:#0e526f; --mint:#dceee9; --gold:#edb950; --good:#13765a; --bad:#a13d34; }
* { box-sizing:border-box; } html { scroll-behavior:smooth; } body { margin:0; min-height:100vh; color:var(--ink); background:var(--paper); font-family:"Avenir Next",Avenir,ui-rounded,system-ui,sans-serif; }
a { color:var(--blue-deep); } .site-header { width:min(100% - 2rem,1160px); margin:auto; padding:1.15rem 0; display:flex; align-items:center; justify-content:space-between; gap:1rem; } .brand { color:var(--ink); text-decoration:none; font-weight:850; letter-spacing:-.04em; font-size:1.17rem; } .brand span { color:var(--blue); } nav { display:flex; gap:1.2rem; } nav a { color:var(--muted); text-decoration:none; font-size:.92rem; font-weight:700; }
main { width:min(100% - 2rem,1160px); margin:auto; } .hero { display:grid; grid-template-columns:1.5fr .78fr; gap:clamp(1.5rem,6vw,6rem); align-items:end; padding:clamp(3rem,8vw,7rem) 0 4rem; border-bottom:1px solid var(--line); } .eyebrow { margin:0 0 .6rem; color:var(--blue-deep); font-size:.73rem; font-weight:850; letter-spacing:.12em; text-transform:uppercase; } h1,h2,h3 { font-family:Georgia,"Times New Roman",serif; letter-spacing:-.045em; } h1 { max-width:13ch; margin:0; font-size:clamp(3.1rem,7vw,6.3rem); line-height:.92; } h2 { margin:0; font-size:clamp(2rem,4vw,3rem); line-height:1; } h3 { margin:.2rem 0 .8rem; font-size:1.45rem; line-height:1.05; } .lede { max-width:58ch; color:var(--muted); font-size:1.08rem; line-height:1.6; } .hero-actions { display:flex; flex-wrap:wrap; gap:.65rem; margin-top:1.5rem; } .button,button { appearance:none; border:0; border-radius:.68rem; padding:.8rem 1rem; color:white; background:var(--blue); cursor:pointer; text-decoration:none; font:inherit; font-weight:800; } .button:hover,button:hover { background:var(--blue-deep); } .button.quiet { color:var(--blue-deep); background:#dceef3; } .hero-note { border:1px solid var(--line); border-radius:1rem; padding:1.2rem 1.3rem; background:var(--card); box-shadow:0 1rem 2.4rem #283d4012; } .hero-note p { margin:.55rem 0; } .hero-note strong { font-family:Georgia,serif; color:var(--blue-deep); font-size:1.5rem; } .note-label { color:var(--muted); font-size:.8rem; font-weight:800; text-transform:uppercase; letter-spacing:.09em; } hr { border:0; border-top:1px solid var(--line); margin:1rem 0; }
.teacher-bridge { display:grid; grid-template-columns:.8fr 1.2fr; gap:2rem; padding:4rem 0; } .teacher-bridge>p { margin:0; color:var(--muted); font-size:1.06rem; line-height:1.65; } .library-section,.interactive-wrap { padding:4rem 0; border-top:1px solid var(--line); } .section-head { display:flex; justify-content:space-between; align-items:end; gap:1.5rem; margin-bottom:1.5rem; } .small { color:var(--muted); font-size:.88rem; line-height:1.5; } .filters { display:flex; flex-wrap:wrap; gap:.45rem; margin:1.25rem 0 1.5rem; } .filter { padding:.52rem .75rem; border:1px solid #cbd7d9; color:var(--blue-deep); background:transparent; font-size:.88rem; } .filter.selected { color:white; background:var(--blue-deep); border-color:var(--blue-deep); }
.unit-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.85rem; } .unit-card { display:flex; min-height:235px; flex-direction:column; padding:1.15rem; border:1px solid var(--line); border-radius:.95rem; background:var(--card); } .unit-subject { margin:0; color:var(--blue-deep); font-size:.75rem; font-weight:850; letter-spacing:.08em; text-transform:uppercase; } .material-count { margin:0 0 .9rem; color:var(--muted); font-size:.86rem; line-height:1.4; } .resource-links { display:grid; gap:.35rem; margin-top:auto; } .resource-link { width:fit-content; color:var(--blue-deep); font-size:.9rem; font-weight:800; } .source-link { margin-top:.85rem; color:var(--muted); font-size:.76rem; }
.interactive-wrap { padding-bottom:2rem; max-width:790px; } .progress { display:flex; gap:.45rem; margin:2rem 0 1.2rem; } .progress span { height:.4rem; flex:1; border-radius:2rem; background:#d8e0e2; } .progress span.active { background:var(--blue); } .session { border:1px solid var(--line); border-radius:1.1rem; background:var(--card); box-shadow:0 1.2rem 3rem #42381610; padding:clamp(1.25rem,4vw,2rem); } .session + .session { margin-top:1rem; } .session h3 { font-size:1.75rem; } .prompt { margin:.9rem 0 1.25rem; font-size:1.1rem; line-height:1.5; } .groups { display:grid; grid-template-columns:repeat(auto-fit,minmax(105px,1fr)); gap:.65rem; margin:1.1rem 0; } .group { min-height:88px; border:2px dashed #b7d0dc; border-radius:.85rem; padding:.65rem; background:#f3fbfd; text-align:center; } .group span { display:block; color:var(--muted); font-size:.78rem; font-weight:700; } .shoes { min-height:42px; padding-top:.45rem; font-size:1.45rem; letter-spacing:.08em; } .answer-row { display:flex; gap:.65rem; flex-wrap:wrap; align-items:center; margin-top:1.15rem; } input,textarea { width:min(100%,320px); border:1px solid #afbac1; border-radius:.65rem; background:white; color:var(--ink); font:inherit; padding:.78rem; } textarea { min-height:112px; width:100%; resize:vertical; } button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible { outline:3px solid var(--gold); outline-offset:3px; } .feedback { min-height:1.5rem; margin:.8rem 0 0; font-weight:700; } .feedback.good { color:var(--good); } .feedback.bad { color:var(--bad); } .tip { color:var(--muted); font-size:.94rem; line-height:1.45; } [hidden] { display:none!important; } .source { max-width:850px; margin:2rem 0 3.5rem; color:var(--muted); font-size:.8rem; line-height:1.5; }
@media (max-width:800px) { .hero,.teacher-bridge { grid-template-columns:1fr; } .hero { padding-top:3.5rem; } .unit-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width:520px) { .site-header,main { width:min(100% - 1.2rem,1160px); } nav { gap:.8rem; } .unit-grid { grid-template-columns:1fr; } .section-head { display:block; } .section-head .small { margin-top:.8rem; } h1 { font-size:3.35rem; } .session { padding:1.15rem; } .answer-row input { width:100%; } }
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { classifyUnit, roleForPdf, isGradeThreeSource } from '../src/catalog.mjs';
test('keeps a Grade 3 unit from the verified source list', () => {
assert.equal(isGradeThreeSource({ title: 'Grade 3 CK Math: Unit 1', filename: 'CKMath_G3U1.zip' }), true);
assert.equal(isGradeThreeSource({ title: 'Grade 2 CK Math: Unit 1', filename: 'CKMath_G2U1.zip' }), false);
});
test('classifies major Grade 3 curriculum families', () => {
assert.equal(classifyUnit('CKMath_G3U1_IntroducingMultiplication.zip'), 'Math');
assert.equal(classifyUnit('CKSci_G3_U1_InvestigatingForces.zip'), 'Science');
assert.equal(classifyUnit('CKLA_G3_Unit-1.zip'), 'Language Arts');
assert.equal(classifyUnit('CKHG_G3_U1_WorldRivers.zip'), 'History & Geography');
});
test('labels teacher guides and student resources without exposing arbitrary paths', () => {
assert.equal(roleForPdf('CKMath_G3U1_IntroducingMultiplication_TG_W2.pdf'), 'Teacher Guide');
assert.equal(roleForPdf('CKMath_G3U1_IntroducingMultiplication_SR_W2.pdf'), 'Student Reader');
assert.equal(roleForPdf('CoreKnowledge_CurriculumSeries_CCL_TermsOfUse_2024_W1.pdf'), 'Reference');
});
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterUnits, subjectsFor, teacherGuidesFor } from '../src/library.mjs';
const units = [
{ subject: 'Math', title: 'Grade 3 Math', materials: [{ role: 'Teacher Guide', id: 'math-guide' }] },
{ subject: 'Science', title: 'Grade 3 Science', materials: [{ role: 'Student Reader', id: 'science-reader' }] },
{ subject: 'Math', title: 'Math Literature', materials: [] },
];
test('lists available subjects with All first', () => {
assert.deepEqual(subjectsFor(units), ['All', 'Math', 'Science']);
});
test('filters units by selected subject', () => {
assert.deepEqual(filterUnits(units, 'Math').map((unit) => unit.title), ['Grade 3 Math', 'Math Literature']);
assert.equal(filterUnits(units, 'All').length, 3);
});
test('selects only a unit’s teacher-plan PDFs', () => {
assert.deepEqual(teacherGuidesFor(units[0]), [{ role: 'Teacher Guide', id: 'math-guide' }]);
assert.deepEqual(teacherGuidesFor(units[1]), []);
});
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isCorrectTotal,
isCorrectEquation,
hasReasoningEvidence,
} from '../src/session.mjs';
test('accepts the total for seven groups of two', () => {
assert.equal(isCorrectTotal('14'), true);
});
test('rejects an incorrect total for seven groups of two', () => {
assert.equal(isCorrectTotal('13'), false);
});
test('accepts an equivalent multiplication equation', () => {
assert.equal(isCorrectEquation('2 × 7 = 14'), true);
});
test('requires a learner explanation to name groups or repeated addition and the total', () => {
assert.equal(hasReasoningEvidence('There are 7 groups of 2, so there are 14 shoes.'), true);
assert.equal(hasReasoningEvidence('It is 14.'), false);
});
@@ -0,0 +1,40 @@
import importlib.util
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
MODULE_PATH = Path(__file__).parents[1] / 'scripts' / 'build_grade3_catalog.py'
spec = importlib.util.spec_from_file_location('catalog_builder', MODULE_PATH)
builder = importlib.util.module_from_spec(spec)
spec.loader.exec_module(builder)
class GradeThreeCatalogTest(unittest.TestCase):
def test_build_catalog_keeps_grade_three_archives_and_labels_pdfs(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
archive = root / 'CKMath_G3U1.zip'
with zipfile.ZipFile(archive, 'w') as zf:
zf.writestr('unit/CKMath_G3U1_TG.pdf', b'not-a-real-pdf')
zf.writestr('unit/CKMath_G3U1_SR.pdf', b'not-a-real-pdf')
zf.writestr('__MACOSX/unit/._ignored.pdf', b'ignored')
manifest = root / 'download-manifest.json'
manifest.write_text(json.dumps([
{'title': 'Grade 3 CKMath: Unit 1', 'filename': archive.name, 'source': 'https://example.test/g3.zip', 'type': 'zip'},
{'title': 'Grade 2 CKMath: Unit 1', 'filename': 'CKMath_G2U1.zip', 'source': 'https://example.test/g2.zip', 'type': 'zip'},
]))
catalog = builder.build_catalog(manifest, root)
self.assertEqual(catalog['unit_count'], 1)
unit = catalog['units'][0]
self.assertEqual(unit['subject'], 'Math')
self.assertEqual([material['role'] for material in unit['materials']], ['Student Reader', 'Teacher Guide'])
self.assertEqual(unit['teacher_guide_count'], 1)
self.assertEqual(unit['student_material_count'], 1)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,31 @@
import importlib.util
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).parents[1] / 'src' / 'server_catalog.py'
spec = importlib.util.spec_from_file_location('server_catalog', MODULE_PATH)
server_catalog = importlib.util.module_from_spec(spec)
spec.loader.exec_module(server_catalog)
class ServerCatalogTest(unittest.TestCase):
def setUp(self):
self.catalog = {
'units': [{
'filename': 'CKMath_G3U1.zip',
'materials': [{'id': 'guide-1', 'internal_path': 'unit/guide.pdf', 'filename': 'guide.pdf'}],
}]
}
def test_known_material_id_resolves_to_its_declared_zip_member(self):
index = server_catalog.material_index(self.catalog)
self.assertEqual(server_catalog.lookup_material(index, 'guide-1'), ('CKMath_G3U1.zip', 'unit/guide.pdf'))
def test_unknown_or_path_like_id_is_not_resolved(self):
index = server_catalog.material_index(self.catalog)
self.assertIsNone(server_catalog.lookup_material(index, '../../etc/passwd'))
self.assertIsNone(server_catalog.lookup_material(index, 'not-in-the-catalog'))
if __name__ == '__main__':
unittest.main()