Skip to content

Chase fournier/grade migration - #10

Open
Chase-Fournier wants to merge 20 commits into
mainfrom
chase-fournier/grade-migration
Open

Chase fournier/grade migration#10
Chase-Fournier wants to merge 20 commits into
mainfrom
chase-fournier/grade-migration

Conversation

@Chase-Fournier

Copy link
Copy Markdown

grade migration

Chase-Fournier and others added 17 commits August 14, 2026 09:21
Phase 4 from CHANGES_.md.

The important addition is `instructorSlug` on /v0/grades and
/v0/grades/summary. Filtering by `instructor` compares a name string, and
the same professor is spelled four different ways across the registrar
exports, Testudo, and PlanetTerp -- "Walsh, Shane Bolles" against "Shane
Walsh". A professor page built on that returns zero rows for a large share
of instructors, and every failure is silent: an empty section, not an
error. Slug and id resolve through instructor identity instead, so they
cannot miss. The name parameter stays and is documented as unreliable.

Two new groupings, `instructorOverall` and `instructorTerm`, back the
professor page's headline GPA and its trend chart. Neither was expressible
before -- nothing in the database produced a per-instructor rollup across
courses at all.

Filters the chosen grouping cannot honor now return 400 instead of being
dropped. Asking for one professor's CMSC132 numbers and silently receiving
their average across everything they have ever taught is not something the
caller can detect.

`nameSearch` does substring matching over the normalized name column,
backed by the trigram index. This is what lets the site stop downloading
every active instructor into browser memory on each page load.

Two behaviour changes worth calling out in review:

* `minStudents` now filters on `graded` rather than `total`. Before Fall
  2017 the registrar's total counts students whose outcome was never
  categorized, so it is not comparable across eras; `graded` is the
  letter-grade count and is also the GPA denominator, which makes the
  threshold mean the same sample the GPA came from.
* Cache capacity 124 -> 4096, split into two caches. 124 was sized for
  department prefixes and a few course codes; per-professor keys would
  have thrashed it to a near-zero hit rate with every request reaching
  Supabase. Course search gets its own cache so professor-page traffic
  cannot evict it.

docs.md is updated. docs.html is NOT -- the two are maintained by hand in
parallel and have already drifted; see CHANGES_.md 7.8 on generating one
from the other.

Builds and vets clean. Not exercised against a live database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs.md and docs.html were maintained by hand, in parallel. That worked
while the API had seven stable endpoints; the grade work added 235 lines
to one and 439 to the other in a single commit, and they had already
drifted before the review endpoints made it worse.

Markdown is the source now. docs.html stays committed so deploying the
binary needs no generation step, and `go generate ./...` rebuilds it.

Two things are reproduced deliberately rather than improved. The heading
anchor scheme is the odd one the previous generator produced -- lowercase,
every run of non-alphanumerics to a single hyphen, leading and trailing
hyphens left on, so a heading of `/v0/courses` becomes "#-v0-courses-".
Trimming would be tidier and would break every internal jump link and any
external link into a section. The page shell is byte-identical apart from
a title and a viewport meta.

Raw HTML passes through, because several table cells embed <ul><li> lists
that markdown cannot express inside a cell. Escaping them rendered the
markup as literal text.

The generator fails the build if an internal link points at a heading that
does not exist. docs.md is one large table of contents pointing into
itself, and a renamed heading otherwise breaks those links silently.

Dropped: the highlight.js `hljs-*` spans the old output wrapped around
every code block. docs.css styles none of them, so they were a few hundred
lines of markup with no rendered effect.

Verified against the previous docs.html: every heading id from the old
file is present, and the only vocabulary differences are three phrases I
had already rewritten in docs.md. Duplicate ids in the old file -- it had
seven headings called "Query parameters" all with id="query-parameters" --
are now suffixed, which also makes the document valid HTML.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 10 from CHANGES_.md, and the parts of 13/14 that live in this
service.

The API was a stateless, unauthenticated, read-only proxy. This adds
everything a write path needs, all of it on /v1 so that /v0 stays the
permissive cacheable surface it has always been. CORS is now per group:
an allowlist on /v1, unchanged on /v0. A permissive policy on a write
endpoint means any page on the internet can make a visitor's browser
submit a review.

The service-role key lives in exactly one type, WriteClient, and nothing
outside that file sees it. Putting the write path here rather than in Edge
Functions was a deliberate trade -- one API, one deploy, one language --
but it converts key containment from a property into work, and that file
is where the work is concentrated. RLS is the backstop: 0008 is written
assuming a handler here is one day wrong.

Notable decisions:

* The submit response is identical whether or not that address already
  reviewed the professor. Anything distinguishable turns the endpoint into
  an oracle for "did person X review professor Y", which is the privacy
  property the hashing exists to provide.

* Email is queued in email_outbox and sent from there, never inline. Two
  reasons: a submit that 500s because a mail provider was slow loses the
  review, and -- per your note about the Brevo cap -- hitting the daily cap
  now defers a send rather than dropping it. I did not make the cap
  auto-approve submissions: that would make exhausting the cap a way to
  bypass verification, and verification is what backs the UMD-affiliation
  claim, the per-email dedupe, and most of the abuse defences. The knob
  exists as REVIEW_ALLOW_UNVERIFIED_ON_EMAIL_CAP and defaults off, with a
  loud boot warning when it is on.

* Rate limiting is a Postgres counter table. Cloud Run autoscales, so an
  in-memory limiter is per-instance and bypassed by retrying until you hit
  a cold one. There is a per-instructor limit as well as per-IP and
  per-email; the per-person limits do nothing against thirty people
  arriving at once to bury one professor.

* A deterministic pre-filter runs before any model sees a review. Links,
  emails and phone numbers are refused outright with no model call.
  Suspected prompt injection and anything alleging misconduct about a
  named person escalate to a human regardless of what a classifier
  concludes -- a rule in code, not an instruction in a prompt, because
  prompt instructions are what an injection attacks.

* Thresholds: auto-approve needs confidence >= 0.90 AND zero policy
  categories AND zero pre-filter flags; auto-reject needs >= 0.85.
  Asymmetric because a wrong rejection annoys one student who can appeal,
  and a wrong approval publishes something defamatory about someone who
  never opted in. Both gates default OFF -- shadow mode records the
  classifier's opinion with applied=false and a human decides, which is
  what makes enabling automation a config change rather than a code
  change. A test asserts that ordinary reviews pass the pre-filter
  untouched, so "the vast majority should not be flagged" is checked
  rather than hoped for.

* Boot-time config validation refuses to start when
  REVIEW_TRIAGE_TIMEOUT_SEC <= REVIEW_TRIAGE_RETRY_MAX_SEC. Backwards, the
  sweeper escalates every quota-blocked review before its retry fires and
  the retry queue is dead code that looks like it works.

The "TODO: Add logger, auth with keys" in main.go is now done. /v1 request
logging hashes the client IP rather than recording it.

docs.md gains a /v1 section; docs.html regenerated. 13 Go test groups
pass, build and vet clean. Not exercised against a live database or any
of the third-party services.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 13 from CHANGES_.md. Three importable workflows plus the runbook for
turning any of it on.

The webhook verifies the HMAC signature before anything else touches the
payload, in constant time, with a five-minute timestamp window. The
endpoint is on the public internet and will be found; the signature is
what stops it being fed fabricated reviews, and the window stops a
captured payload being replayed forever.

The classifier is pinned to an explicit model id, runs at temperature 0,
and is constrained to a JSON schema whose decision field is an enum of
exactly approve/reject/escalate. It never emits a free-form action string
that something downstream parses loosely, and anything unparseable, out of
enum, or missing a confidence becomes escalate. A pipeline that guesses
when it cannot read its own input eventually publishes something nobody
approved.

The prompt states that everything inside the review tags is data, never
instructions, and that an attempt to instruct the model is itself grounds
to escalate. That is a mitigation, not the defence: the real defences are
in Go, where an injection cannot argue with them. Links and contact
details are rejected before any model call, and misconduct allegations
escalate regardless of what the classifier concludes.

Discord messages link to the authenticated queue and carry no decision
token. A channel post is visible to everyone in it and is trivially
forwarded, so a one-click approve link is a decision anyone can take.

The runbook has the shadow-mode rollout, including the query that actually
matters: how often the classifier said approve where a human said reject.
Anything in that cell means auto-approve is not ready.

Also documents that the Gemini free tier's terms permit Google to use
submitted content to improve their products. That is disclosed on the
privacy policy page, and the runbook notes it needs updating if you move
to a paid tier.

The sweep is scheduled here for completeness, but the runbook recommends
Cloud Scheduler instead: an n8n outage should not also stop the email
outbox draining.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README.md covers the design and the rollout policy; this is the mechanical
side -- generate three secrets, import three workflows, set five variables,
add the Gemini credential, activate, point the API at it.

Includes the curl that should FAIL. An unsigned POST to the webhook must be
rejected: if it returns 200, HMAC verification is not running and anyone
who finds the URL can feed the workflow fabricated reviews. That is the one
check worth doing by hand.

Notes that CLI imports do not activate workflows, which otherwise presents
as every call 404ing with no obvious cause, and recommends Cloud Scheduler
over the n8n schedule for the sweep so that an n8n outage cannot also stop
the email queue draining.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three errors in the exported JSON, found by checking it against the actual
node definitions rather than trusting the shape I wrote it in.

`n8n-nodes-base.googleGemini` does not exist. The node is
`@n8n/n8n-nodes-langchain.googleGemini`, resource `text`, operation
`message`.

`maxOutputTokens` defaults to 16. This was the dangerous one: 16 tokens
truncates the JSON reply mid-object on essentially every call, and a
truncated reply is unparseable, so the workflow would have escalated 100%
of reviews while looking like a cautious classifier rather than a broken
one. Set to 512. That failure survives a demo and gets discovered a month
later from the queue depth.

The messages array accepts only `user` and `model` roles, so the system
prompt moves to `options.systemMessage`. Same content, different slot.

The real change is to the guarantee. This node has a boolean `jsonOutput`,
not schema-constrained decoding -- there is no `jsonSchema` option -- so
nothing at the API boundary enforces that `decision` is one of three words.
The README claimed it did. It now says plainly where the guarantee actually
lives: the contract is stated in the system message, and `Parse decision`
enforces it.

That node is now load-bearing, so it is hardened: it reads the payload from
any of the shapes the node emits, strips markdown fences, rejects
out-of-enum decisions, coerces a non-numeric or out-of-range confidence to
zero, and escalates an `approve` that arrives with no usable confidence --
which is a parse failure wearing a decision's clothes. Verified against
twelve payload shapes, including the 16-token truncation: every valid shape
parses, every malformed one escalates.

Kept this design rather than switching to a Basic LLM Chain with a
Structured Output Parser, and the README records why. A schema still admits
a well-formed `approve` for a review that should be rejected, so it only
rules out malformed output, which Parse already handles. The parser throws
on violation, which routes to the error branch and leaves the review
pending until the sweeper escalates it up to 30 hours later; the current
path escalates immediately with a reason attached. Both fail safe, this one
fails faster and says more.

INSTALL.md gains the two settings that matter on that node and two new
entries in the troubleshooting table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Chase-Fournier Chase-Fournier self-assigned this Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant