projects / easemysemester / features
Answer schema tracks staleness by construction
v2's answer table records the embedding config and prompt version an answer was built from, so a re-ingest or a prompt edit turns stale answers into a query result rather than an invisible drift.
feature · current · updated 2026-09-15 · EaseMySemester case study
See overview, v2, and citation provenance.
What it does
Each row in answers carries config_id, a foreign key to the embedding config the answer was
generated from, in addition to chunk_ids (what the model read), model and prompt_version
(what produced it). config_id is NOT NULL and its foreign key RESTRICTs deletion, so an
active config can never be removed out from under answers that depend on it.
A change to the corpus's chunk size re-ingests and renumbers every chunk, which would otherwise
make a stale answer indistinguishable from a current one. With config_id recorded, staleness
becomes a comparison against the active config rather than a guess, and the generation script's
--stale flag (generation script)
selects exactly the answers a re-ingest or a prompt edit left behind.
chunk_ids deliberately carries no foreign key. A cascading delete would silently empty an
answer's provenance on the next re-ingest, and a restricting one would block re-ingest for as
long as any answer exists, turning a routine sweep into a migration. A dangling chunk id is
correct here: the column is a record of what the model read at the time, and whether that
record is still current is answered by comparing config_id, not by the foreign key.
Status is a paired constraint, not a convention
status is ok or declined, and two database constraints make the pairing unrepresentable
any other way: an ok row must have non-empty condensed and detailed text, and a declined
row must have neither, plus a decline_reason. A first-class declined status treats "the
source material does not cover this" as a correct outcome, not a failure
(citation provenance). A declined
answer still records chunk_ids and config_id, so a later reviewer can tell a decline caused
by thin sourcing from one caused by a bad prompt.
A separate check caps condensed at 1,200 characters, roughly twice any reasonable answer, as a
guard against a runaway generation rather than a definition of the target length; the actual
target, a phone screen read without scrolling, is set by measuring real answers, not by this
guard.
Two review rounds after the initial migration
The first schema (migration 0006) stored citations as a JSONB column, which accepted any
shape the generator produced, including a fabricated book. Migration 0007 replaced it with a
separate answer_citations table whose book_id is a foreign key to books, so a citation
naming a nonexistent book fails at the database rather than merely looking wrong. Migration 0007 made the "a complete answer has at least one citation" rule a constraint trigger that fires at commit, so the generator's answer-and-citations write can land as one transaction, and migration 0008 made the citation triggers deferrable and folded in the pairing gap, so a non-complete answer can no longer carry citations.
Status
Shipped 2026-09-07 (issue #14), reworked the same day after review found the JSONB blob's gap,
and had its citation-pairing trigger made deferrable later the same day (migration 0008, issue #15 review). Verified live against production: the schema applies, and constraint tests reject an
ok row with no text, a declined row carrying text, a second answer for one question, and a
citation naming a book that does not exist.