← Back to projects Personal project

A Curious Thing. One image. One story. Every source real.

An automated newsletter that digs through the world's public archives and comes back, every other day, with one remarkable image and five minutes of properly researched writing about it.

Type
Independent personal project
Built with
Python (async) · httpx · pydantic · Jinja2 · SQLite
AI
Claude or OpenAI, behind one interface
Runs on
GitHub Actions, on schedule
11public archives searched
~120 → 1candidates down to one story
10pipeline stages
265tests — no network, no keys

The cost design

Expensive work only happens to survivors.

The pipeline is a funnel. Discovery and filtering cost nothing; the model is only called on candidates that already survived cheaper checks — and the biggest calls happen once, on a single winner.

~120discovered — archive APIs only, no AI tokens
24prefiltered — free heuristics
6triaged — one batched call to a small model
3researched + fact-checked
2scored editorially
1written + reviewed

Architecture

Ten stages, and refusing is the default.

AI stepGateDeterministicExit — rejected here
1 · DiscoverWikimedia, NASA, Library of Congress, the Met, Smithsonian, Europeana…
→
Licence gateonly clearly reusable images pass
→
2 · Prefilterfree heuristics
→
De-duplicatenever repeat an image, subject or topic
→
3 · Triageone batched call
→
4 · Researchinstitutions, not summaries
→
5 · Fact-checkadversarial; weak claims dropped
→
6 · Scoreplus rotation across topics
→
7 · Write500–900 words
→
8 · Quality controlmechanical + editorial
→
9 · RenderHTML + plain text
→
10 · Sendand remember in SQLite

If a draft fails quality control it is rewritten once, then abandoned for the runner-up. If nothing clears the bar, there is simply no edition that day.

Trust & safety

It reads strangers' text. So it treats it like a supply chain.

Prompt injection

Four layers, none trusted alone

  • Strip scripts, hidden and direction-flipping characters first
  • Fence outside text in blocks with a random per-run marker it can't forge
  • Label every block as evidence, never instructions
  • Flag seven injection patterns and warn the model inline
Fabrication

Citations enforced in code

  • Every cited URL must be one the pipeline actually fetched — invented links are discarded
  • A claim left without a real citation is downgraded
  • The sources list is built from research data, not from the model's reply
  • Thin research is rejected before anything is written
Network

Fetch only what it should

  • Research limited to an allowlist of institutions
  • Private, loopback and reserved addresses refused — redirects can't probe internal networks
  • Look-alike domains and unsafe URL schemes rejected
  • Secrets kept out of prompts and redacted from logs

Key design decisions

Simple where it can be, strict where it must be.

The mechanical gate can't be overruled

Length, attribution, sourcing depth, licence and safety are checked in code and block publication. The model's own review can lower a score, but never raise one past a mechanical veto.

Wikipedia as an index, not an answer

Research reads Wikipedia for orientation, then follows its references to the museums, agencies and journals that hold the evidence. Wikipedia alone can't support a key claim.

Measure what you can; ask the model the rest

Source quality and image quality are computed from the pipeline's own data — a model is a poor judge of its own evidence.

No embeddings where overlap will do

"Is this basically Tuesday's story?" is answered with token overlap on titles and entities: free, dependency-free and good enough — swappable later if it isn't.

Async everywhere

Eleven archives and several research pages per candidate are fetched concurrently, turning a fifteen-minute run into about two.

Five runtime dependencies

pydantic, httpx, Jinja2, PyYAML and python-dotenv. No ORM for a nine-column table, no web framework when there is nothing to serve.

Want to talk about it?

Happy to walk through the architecture and trade-offs.