class-audio-harvest: class recordings through a frontier model, minus the student records
A pipeline that turns a Zoom class recording into teaching findings. Deterministic facts first, a redaction gate sized to the measured risk, and a byte-identical approval before anything leaves the machine.
- Status
- in use
- Stack
- Python, Typer, WebVTT, Ollama, Canvas API, Claude, pytest
- Essay
- Arithmetic and retrieval are code's job. Judgment is the model's. · Measure the risk surface before you design the control
The problem
I teach four sections of the same course. Zoom records each one. A 69-minute session is about 559 caption cues, and somewhere in there is the demo that ran long, the slide I argued with, the figure I hedged, and the analogy that actually landed. Reading transcripts by hand does not scale to eight sections a week.
The constraint
Two of them, pulling in opposite directions.
Local models can extract but cannot fact-check. I measured this on one session with an 8B model and a 14B model against a frontier model. Both local models found what I said. Neither caught the six places I said it wrong, and the bigger one laundered a wrong figure into a recommendation for a new slide. So the useful analysis needs a frontier model.
Creighton has no agreement with the frontier-model vendor, so no school-official designation applies. Nothing that leaves my Mac may be an education record.
What I built
Three pieces, in the order they run.
A facts layer that needs no model. A parser computes session length, cue count, timeline anchors, silences over 45 seconds (where demos and work blocks hide), every line that states a figure, and every line matching a tell phrase like “we did that part already.” The prompt opens by telling the model these were measured and must not be recomputed. Arithmetic and retrieval are code’s job. Judgment is the model’s.
A redaction gate sized to the measured risk. Before designing anything I scanned one session. Zoom attributed every cue to the instructor. An over-inclusive scan produced 85 candidate tokens; reading all 85 found one student name. Zero emails, zero phone numbers, zero ID-shaped strings. So the job was one name, not a document full of them. The control has three layers in descending order of trust: Canvas roster matching, a diminutive map (rosters carry legal names, rooms use nicknames), and a human review list that shows each remaining candidate in its surrounding sentence. The human layer caught the only real hit, a first name on no roster at all. The full reasoning is in the essay Measure the risk surface before you design the control.
A fail-closed approval. What is reviewed must be byte-identical to what is sent. The payload is written once as one file. Approval records a sha256, never a filename. Re-staging deletes any existing approval. The gate runs again at send time on the same bytes, and every refusal raises. A review of roughly-what-will-go is theater.
What it cost or taught
This started life inside recap, my local meeting recorder, whose privacy rule is that nothing leaves the machine. Class transcripts have the opposite posture: they are education records that get de-identified and then do leave, through a gate. Two privacy postures in one repo meant the privacy test needed an exception and the README had to warn people off half the commands. So I split it. Each repo now has one invariant with no exceptions, which is the only kind of invariant that holds.
The review page has to show what was removed, so by necessity it contains the original names. It is named review.local.html, gitignored, and the send path returns the payload alone rather than the directory. A test asserting “no file in the bundle contains a name” fails on it, correctly. The fix was the naming and the return value, not weakening the test.