Living document. Written as decisions get made, not after the fact. A writeup reconstructed in hindsight always shows — it loses the paths not taken, which is the interesting part.
No LangChain, no CrewAI, no LangGraph. The pipeline is N sequential steps with a verification gate. A function expresses that better and is debuggable with pdb.
LangGraph earns its keep with cyclic state and conditional branching. The only part of this system that will have that is retrieval for obscure titles ("not enough results, reformulate and search again"). When we get there, it gets re-evaluated — and if it's introduced, it'll be scoped to just that node.
Cost of this decision: if the project grows into real branching, we'll have to rewrite orchestration. Accepted trade-off: cheaper than carrying the abstraction from day one.
Claim.source_id is optional even though a fact without a source is
unacceptable.
If Pydantic rejected it at parse time, the failure would be an exception instead of a metric. And what we need to know isn't "did it fail?" but "what percentage of cases, in which stratum, and how much does the intervention reduce it?"
Validation lives in the verifier, which is a named, numbered stage.
In the original prompt the model self-labeled 🟢/🟡/🔴. Self-evaluation: the same component that can leak a spoiler certifies that it hasn't.
Here the tier is assigned to the retrieved documents, before generation. The pre-show generator only receives GREEN. AMBER is treated as RED by default (cost asymmetry: one fewer bullet vs. a published spoiler).
This turns security into a property of context instead of an instruction. Instruction = the model can disobey it. Context = it can't reveal what it doesn't have.
leakage and grounding without richness reward silence: an empty brief
scores perfectly on both. Covered by a test
(test_empty_brief_scores_perfectly_on_safety) so it can't be forgotten.
The safety/richness trade-off is the project's result, not a detail.
With planted leaks and a known answer. If the metric doesn't reproduce the expected number, the metric is broken. Caught before spending on the API.
Side effect: tests run in CI with no secrets.
That would measure the model's coherence with itself. The exact failure this project exists to catch. 15 min/title, by hand.
Original rule: no LLM (neither Claude nor the user delegating to one) generates spoiler ground truth, because that would measure the model's coherence with itself — the same failure this project exists to catch.
The user reverted this on 2026-07-25 to be able to scale labeling across 20 titles without spending ~15 min/title by hand. His call, made with the trade-off explicitly on the table.
Partial mitigation (doesn't eliminate the risk, bounds it): every
canonical is built from real cited sources (Wikipedia, reviews, vlogs)
instead of the model's parametric memory — not the same as "hallucinating"
a plausible spoiler. But it's still an LLM deciding what counts as a
spoiler and how severe it is, evaluated later by another LLM (the
generator) and judged by a third (the judge). Three stages of the same kind
of bias.
Mandatory consequence: the README and any published result must say "ground truth researched by an LLM with cited sources," never "human ground truth" or "hand-labeled." Those are different claims, and conflating them would reintroduce the original problem (self-labeled traffic light, see D3) through another door.
The catalogue (/api/catalogue, visible before picking a title) now
includes director, themes, and awards_count per movie, so you can
filter/sort without opening the entry. director and themes are new
metadata, no conflict there. awards_count is different: it's
len(critical_consensus.awards), and the full critical_consensus lives
in POST_SHOW_FIELDS (D3) because its summary can carry critical
commentary with plot nuance.
Only the count is exposed, never the awards list or the summary. A
number ("3 awards") isn't a plot spoiler under any reasonable reading —
unlike summary, which could hint at twists ("the performance that
carries the final reveal"). The D3 partition protects spoilers, not every
post-viewing data point equally; a narrow, explicit exception for a
non-narrative derivative doesn't reopen the problem D3 solves.
The ES/EN toggle originally translated only the app chrome (labels, section
titles) — the researched prose (content/researched/*.json) stayed English,
since hand-writing a Spanish mirror of all 7 titles wasn't worth it for a
portfolio demo. Once that gap was visible in the running app, the fix
chosen was automatic translation (MyMemory's free API, no key, stdlib
only — src/preshow/translate.py) rather than hand-translating every
title, on the explicit tradeoff of lower quality for zero cost and no new
paid dependency.
Two things this pushed into the design, both learned by hitting them while building it, not decided up front:
- Cache honestly or don't cache. MyMemory's free tier throttles hard
(a burst of concurrent calls returns 429 almost immediately), and the
first version of this cached whatever came back — including an
all-English result when the API was fully rate-limited, indistinguishable
from a real translation and cached forever.
translate_pack_dumpnow returns(dump, translated_anything); a run that translated nothing is never cached, so the next request retries instead of being stuck showing English while claiming otherwise. - Say so in the UI. A researched entry viewed in Spanish shows a small
"machine-translated" tag (
auto_translatedin the API response). Same principle as D3/D6/D7: don't let the UI imply a guarantee (native-quality, cited translation) the pipeline doesn't back up.
themes (canonical filter vocabulary matched against the frontend's
THEME_META), director, sources (URLs), and every source_id/kind
are deliberately never translated — translating them would break matching
or misattribute a source, not just read oddly.
Scaling "how many movies does the app know about" past the 20-title
measurement set could have meant hand-writing more content/researched/*.json
entries (doesn't scale — each one is real research work) or bulk-importing
TMDB data as if it were curated content (would quietly dilute what
"researched" means: cited claims, no invented facts, per D6/D7). Neither
is right. Instead, TMDB is a third tier, structurally kept apart:
| Tier | Source | Size | Has a curtain / cited claims? |
|---|---|---|---|
| Measurement | evals/dataset/titles.yaml |
20, fixed | N/A — it's the experiment |
| Researched | content/researched/*.json |
8, hand-written | Yes |
| Browse (TMDB) | src/preshow/tmdb.py |
effectively all of TMDB | No — poster + synopsis only |
Concretely:
- Live search, not a bulk import.
GET /api/searchhits TMDB on demand (cached per query incontent/_tmdb_cache/, gitignored) instead of downloading and committing a fixed list of "popular" titles. The catalogue's real size stays 20 + whatever's been researched; TMDB just fills the "search for anything" gap without bloating the repo or pretending a bigger local dataset means more research got done. - The 20 measurement titles get real posters too.
TitleCase.tmdb_idexisted in the schema unused;webapp/resolve_tmdb_ids.pyresolves it for all 20 (by title+year, falling back to the top search hit — a handful land on TMDB's release-date year rather than the production year, e.g. Coherence 2013→2014) and writes it back intotitles.yamlas a text edit, not a re-dump, so the file's stratification header/comments survive untouched./api/film/{id}uses it for abrowsefallback (poster + real overview) on the 13 not-yet-researched titles, instead of the old bare "not researched yet" placeholder. - The "+ Suggest a movie" button resolves against TMDB too. Typing a
title autocompletes against
/api/search; picking a result attaches itstmdb_idto the saved suggestion (content/movie_requests.json) so whoever researches it later doesn't have to re-identify the exact film, release year, or check for a same-title remake. - Attribution is non-negotiable. TMDB's free-tier terms require
visible attribution wherever their data is shown. The browse card and
the not-yet-researched fallback both carry a fixed attribution line
(
tmdbAttributionin the i18n dict) — not a maybe, a ToS condition of using the API for free. - TMDB's overview text is never run through the leak detector.
verify_pre_show()only ever measures the researched tier's own pre-show text (same input it always had); showing a TMDB synopsis on the browse fallback is a display concern, not a new measurement claim.
Most free hosting tiers (the ones this project targets, per D-notes above)
wipe the filesystem on every restart or redeploy. content/comments.json
and content/movie_requests.json were plain local files — meaning any
public deploy would silently reset both on the next redeploy, with no
error and no warning.
The fix (src/preshow/kv_store.py) is a single JSON-blob read/write pair
backed by Upstash Redis's free REST API (no card; REST means no persistent
connection to manage, which fits a small box that may spin down between
requests) — not a database migration, not an ORM, just the same
read_comments()/write_comments() shape the app already had, now able
to point at a key that survives a restart instead of a file that doesn't.
Two choices worth being explicit about:
- Local dev needs no Upstash account at all. Without
UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKENin.env,kv_storefalls back to the exact local-file behavior the app always had. The persistence layer is opt-in infrastructure for deployment, not a new requirement for running the app locally. - Reads degrade quietly; writes don't. A failed read falls back to an empty list/dict (a title with no comments yet looks the same as a title whose comments failed to load — an acceptable, low-stakes degradation). A failed write raises instead of returning success: a user who thinks their comment posted, when it silently didn't persist, is a worse failure mode than a visible error.
D7's internal calibration (evals/calibrate_substring.py) tests
SubstringJudge against LLM-written paraphrases of LLM-written ground
truth — the same self-coherence problem this project exists to catch,
one level down. It gives a real number, but not an independent one.
evals/calibrate_substring_external.py fixes that by testing against the
IMDB Spoiler Dataset (Misra) — real IMDb users' own review text, tagged
is_spoiler by other real users, never touched by any LLM in this
project. Manually downloaded (Kaggle needs a free account, no API key;
this script must not request or manage that credential — same reasoning
as D11 for Upstash), gitignored under evals/dataset/external/ (~570k
reviews, not ours to redistribute), streamed line-by-line instead of
loaded whole (~950MB).
Named task mismatch, stated rather than hidden: the dataset labels a
whole review as spoiler/not, not which plot point it reveals.
SubstringJudge checks for one specific, documented SpoilerLabel. The
script bridges this by testing "does the review contain ANY of this
movie's documented labels" — which means a review correctly tagged
is_spoiler=true that spoils something we didn't document (we only
label headline twists, not every beat) scores as a false negative that
isn't really the judge's fault. This inflates measured misses; it can't
manufacture false hits. Recall is a floor on the judge's true blindness,
not an exact number.
Result, restricted to the 9 of our 20 titles the dataset actually
covers (mainstream titles dominate — the dataset is old IMDb review
data, and long-tail titles here mostly have no review coverage at all,
itself a small real-world echo of the mainstream/long-tail split this
project studies): 0 of 2,197 real spoiler-tagged reviews detected —
recall = 0.0, matching the internal calibration exactly, this time
against text nobody in this pipeline wrote. SubstringJudge's blindness
is no longer a self-check; it's confirmed against an independent source.
Precision is undefined (0 positive predictions made at all — not "made
bad calls," see D4's empty-output caveat for the same shape of mistake),
not "0.0" in the sense of being wrong on every guess.
Consequence for the Next task: this closes "find an external benchmark"
as an open question, and reframes what's actually blocking Milestone 1.
It isn't finding calibration data anymore — it's that SubstringJudge
is now proven, twice, unfit to report a trustworthy leakage_rate at
all. LLMJudge already exists in evals/judge.py (unused, unwired) as
the real next component to bring in before drawing any mainstream vs.
long-tail conclusion, calibrated the same way against these same 2,197
human labels.
evals/calibrate_llm_external.py wires LLMJudge to Groq's free tier
(llama-3.1-8b-instant — a small model on purpose, matching judge.py's
own rationale that entailment is "a short binary call, doesn't justify a
large model") and re-runs D12's exact method (real IMDb reviews, "entails
ANY of this movie's documented labels", same labeling-coverage caveat) —
the direct comparison the project's own next task called for.
Budget forced a smaller, different sample than D12's. Groq's free
tier caps this key at 1,000 requests/day, and each review costs
len(that movie's labels) calls — the full 7,657-review set would be
~20,000 calls, not possible for free in one day. Ran a fixed, seeded,
stratified sample instead: 20 reviews/title (10 spoiler-tagged, 10 not)
across the same 9 covered titles — 180 reviews, 480 actual LLM calls.
Reviews were also truncated to 350 characters/call to stay under the
~6,000 tokens/min cap.
Result:
| Judge | n | Recall | Precision |
|---|---|---|---|
SubstringJudge (D12, full 7,657-review set) |
7,657 | 0.0 | undefined (0 positive predictions) |
LLMJudge / llama-3.1-8b-instant (this, 180-review sample) |
180 | 0.089 (8/90 caught) | 0.471 |
Read this straight, not rounded up or down:
- A real, measured improvement over the free floor.
LLMJudgesees paraphrasesSubstringJudgestructurally cannot (0.0 recall by construction). Not nothing. - Still not trustworthy on its own. 8.9% recall means it misses ~91 of
every 100 real spoiler reveals in this sample. Precision 0.471 means
fewer than half its positive calls are right. Neither clears a bar
where this judge's
leakage_ratecould be reported as a safety claim. - Two confounds this run can't separate, worth resolving before
concluding "the model is the ceiling": (1) truncating reviews to 350
characters for TPM budget reasons may suppress recall independent of
the model's real ability — a fair test needs full review text; (2)
llama-3.1-8b-instantis the smallest, fastest model available, chosen for the same reasonSubstringJudgeexists as a cheap floor — a stronger model (llama-3.3-70b-versatile, or Claude) tested the same way would show whether this is a model-capacity ceiling or a budget/truncation artifact. - 180 reviews, one small model, one free-tier run is a smaller claim than
D12's full 7,657-review
SubstringJudgeresult — reported at that size, not silently generalized past it.
Next task implication: neither judge is currently fit to report a
trustworthy leakage_rate. The path forward isn't "ship LLMJudge
instead of SubstringJudge" — it's resolving the two confounds above
(full text, stronger model) or exploring genuinely different approaches
(a lightweight NLI/entailment classifier run locally, no per-call cost or
rate limit; or training a classifier directly on this project's own
2,197-positive/5,460-negative external labels, held out properly) before
trusting any leakage number this project reports.
The demo track's 8 hand-researched titles took ~15 min each. The user's goal is a much longer list of the most important films in cinema history, "de forma gratuita y veraz" (free and truthful) — which raised a real question: does scaling that mean relaxing D6/D7 (every fact needs a real, checkable source)? Explicitly asked and explicitly answered: no. What gets automated is finding and reading sources and drafting from them, not the citation bar itself, and not the human review before anything is published.
webapp/research_assist.py implements this:
- Real retrieval, not memory.
src/preshow/wikipedia.py(new, stdlib-only, same pattern astmdb.py) fetches a film's Wikipedia article and splits out plot/production/reception/accolades text.tmdb.get_director()(new) adds the director via TMDB's credits endpoint. The LLM (Groq, free) drafts aContentPackusing ONLY this retrieved text — the prompt forbids grounding a claim in anything else, matching the no-fabricated-source rule already established for the baseline generator. - A code-level safety net, not just a prompt instruction.
sanitize_grounding()walks everysource_id/urlin the draft and nulls out anything that isn't exactly one of the URLs this run actually retrieved. This isn't hypothetical: the first test run had the model cite a specific-lookingrottentomatoes.comURL for a score, despite never being given that source — Rotten Tomatoes/Metacritic aren't fetched at all (no simple free API for either), so any score they're credited with in the draft must trace back to Wikipedia's own reporting of it, urlnull. Same lesson as D3: don't trust the generating model to police its own citations; enforce it in code that runs after generation. - Output goes to
content/_drafts/(gitignored), never straight tocontent/researched/. A human review pass is still the actual quality gate — what's automated is turning "write from scratch" into "review and edit," not eliminating the check.
Honestly reported limitation, found by testing on one real title (Citizen Kane) across
three prompt iterations: a single generation call from a small free model is
inconsistent — the same prompt against the same retrieved text produced anywhere from 4
to 15 grounded claims run to run, with no temperature pinned. Two concrete prompt bugs were
found and fixed this way (a fabricated-looking "score" entry with no real source behind it;
questions and debate_prompts coming back as literal duplicates) — both confirmed fixed
by direct comparison of successive drafts.
Fix implemented and confirmed working live. draft_best_of() generates 3 independent
candidates per title from the same shared retrieval (temperature=0.8 for real diversity),
sanitizes each candidate's citations first (so a candidate can't win by fabricating extra
ones), and keeps the one with the most grounded claims; main() now defaults to this
instead of a single draft() call. First committed without a live end-to-end run — Groq's
API returned a network-level 403 ("Access denied, check your network settings") for every
request from that session's execution environment specifically (a bare curl to
/v1/models failed the same way; the user confirmed it worked from their own
browser/terminal, with or without their VPN, the whole time). The 403 resolved on its own
in a later session with no code change and no identified root cause (possibly a temporary
Cloudflare IP flag) — python webapp/research_assist.py "Citizen Kane" 1941 3 then ran
end-to-end for real: all 3 candidates succeeded (4, 4, 5 grounded claims), the 5-claim one
was correctly selected, zero fabricated citations reached the output.
One real, minor quality issue surfaced by that live run, left as a future prompt fix, not a
grounding violation: author_voice came back as the model's own generic critical
commentary ("As a film-literate critic, I approach...") rather than an actual quote or
statement from someone who made the film (director, screenwriter) — the field's intended
content, as seen in Gone Girl's Fincher/Flynn quotes. It still cites the retrieved
Wikipedia source correctly; it's a category mismatch, not a fabrication.
- Multi-agent (researcher / writer / critic). No dynamic decision to delegate. Adds non-determinism and cost in exchange for aesthetics.
- Markdown as the generator's output. It's a render layer. The data is typed JSON, or the path to TTS + editing never lands.
- Books in v1. Different retrieval, no equivalent to RT/Metacritic or
production data.
SourceAdapteris designed for two domains, only one gets implemented.
The project is meant to run without a paid API key. One follow-up left (the TMDB browse tier from D10, and the free-tier baseline generator below, are both done):
- Free-tier baseline generator — implemented, blocked only on a key.
AnthropicBaselineGenerator(src/preshow/baseline.py) needed a paidANTHROPIC_API_KEYto unblock Milestone 0.GroqBaselineGenerator(src/preshow/baseline_groq.py,--generator baseline-groq) is the sameGenerator— identical prompt and output schema, shared verbatim viabaseline_prompts.pyso a leakage_rate difference between the two would measure the model, not a prompt drift — against Groq's free tier (no card required). Get a key at console.groq.com/keys, setGROQ_API_KEY, then runpython evals/run_eval.py --generator baseline-groqto get real numbers into the README. Milestone 1 (retrieval) still shouldn't start before those numbers exist. - Automating the "+ Suggest a movie" pipeline. The webapp captures
suggestions (
POST /api/requests, appended tocontent/movie_requests.json, gitignored) and now resolves the exact film via TMDB autocomplete (D10) so a suggestion carries atmdb_id, but it still does not research or add anything automatically. Turning a suggestion into acontent/researched/*.jsonentry has to meet the same bar as the existing 8 — cited sources, no invented facts (D6/D7) — which is a real research pipeline with a review step, not a one-request LLM call. Deliberately left as a manual step until that pipeline is designed.
- What's the AMBER threshold? Depends on judge calibration.
- What does the judge cost per case? len(surface) x len(labels) calls. Probably the most expensive component of the pipeline. Measure before optimizing.
Find a public, directly-downloadable spoiler benchmark to calibrate the judge against.Done — see D12.Wire upDone — see D13: recall 0.089, precision 0.471 on a 180-review sample, better than the floor but not trustworthy yet. Open now: re-test with full (untruncated) review text and/or a stronger model to separate a real capability ceiling from a budget/truncation artifact, or evaluate a local NLI classifier / a classifier trained on this project's own 2,197/5,460 external labels as a genuinely different approach — before trusting anyLLMJudgeand calibrate it the same way.leakage_ratethis project reports.