Skip to content
JONAS STAMM
— FIELD NOTES
technical-deep-dive— October 14, 2026· 6 min read

Citations Did Not Stop Our AI From Inventing a Case Study

Our blog pipeline once wrote an invented case study: a draft about shipping BauGPT's WhatsApp bot in 10 days, with a cost breakdown in the headline. The...

JS
Jonas Stamm
Founder, BauGPT

Our blog pipeline once wrote an invented case study: a draft about shipping BauGPT's WhatsApp bot in 10 days, with a cost breakdown in the headline. The pipeline had a rule that every stat needs a source, and the draft followed the rule. The rule did not stop it.

That is the failure I want to write down, because the obvious fix is the wrong one. When a model makes things up, the first instinct is to tell it to cite its sources. We had done that. The agent satisfied "cite your stats" and still invented a whole fictional case study. A sourcing rule checks that a citation exists. It does not check that the citation says what the sentence says.

Why "cite your sources" is not a gate

An instruction is a request. A model under pressure to produce a good post will meet the letter of the request with the least effort. If the rule is "every number needs a source", the cheapest way to comply is to attach a source to every number. Nothing in the rule says the number has to be in the source.

The draft was the clean version of this. It read well and its numbers were specific. None of it had happened. Because the pipeline only produced drafts, the damage stopped at a draft. The lesson we wrote on the ticket was short: a draft-only pipeline still needs a fabrication gate, not just a sourcing gate.

A sourcing gate asks "did you cite something". A fabrication gate asks "is the thing you said actually in the thing you cited". The second question is the one a reader assumes you asked.

The gate that works is boring

What we run now does not ask a model to judge truth. It is a string check in Python and TypeScript, and the pipeline runs it on every draft before it opens a pull request.

The draft is split into sentences. Any sentence that states a number has to carry a citation to an item in this week's evidence pack. The evidence pack is built from git history and closed tickets, not from the model. Then the check takes each number in the sentence and looks for it, as a literal string, in the text of the items that sentence cites. If the number is not there, the sentence is cut. If the sentence cites an item that is not in the pack, it is cut. If it states a number and cites nothing, it is cut. Prose with no number and no citation is left alone, because that is argument, and argument is the writer's job.

ASIDE

The check is dumb on purpose. It cannot tell a true sentence from a misleading one that reuses a real number. It only refuses digits that came from nowhere, and that is the failure we actually had.

Every cut sentence goes into the pull request under "Dropped claims", next to a table that maps each surviving claim to the ticket or commit it came from. I review that table, not just the prose. A model can write a convincing paragraph. It cannot make a number appear in a closed ticket that does not contain it.

There are real costs. The check does not understand units, so "forty thousand" in a ticket and the same figure written as digits in a draft do not match, and the sentence gets cut. A number spelled out in words is not a number to the check at all, so it passes untested. It does not understand context, so a number lifted from the right ticket into the wrong claim passes. The first costs a sentence and fails closed. The other two need a human who reads the draft, and the claim table is what makes that review fast.

The same move, applied to the blog itself

The pattern is not specific to writing. Around the same time we built a weekly health report for the blog that checks publish cadence, review-queue depth, pillar drift, sitemap against the post file, image coverage and the status of the blog crons. It took 0.6 hours to build.

The interesting part was what it found on the first real run. Social preview images looked fine if you counted files: there were 10 files on disk. Matched against the posts that actually use them, coverage was 3/21, or 14%. Most of the images pointed at stale slugs. Counting files was a proxy. Matching each post to its image was the check.

The same build surfaced a second version of the lesson. The cron-status check matched its own job name, because that name also contains "blog", so the check could report healthy by finding itself. The fix was to whitelist only the real auto-publish and draft-pipeline jobs. A check that passes by finding itself is the same bug as a citation that exists but does not say what the sentence says.

What I would tell anyone building one of these

Do not ask the model to be honest. Build a check that catches it when it is not. Concretely:

Build the evidence from systems the model does not write to. Git history and closed tickets work because they exist before the draft does.

Check the claim against the evidence, not against the presence of a citation. A literal string match on numbers is crude, and it catches the exact failure that a sourcing rule lets through.

Put the cut claims where the reviewer will see them. A gate that drops sentences quietly teaches nobody anything. A list of dropped claims in the pull request tells you, every week, what the model tried to get away with.

Keep a human on the merge. The string check removes fabricated numbers. It does not decide whether the argument is any good, and it should not try.

This post went through that gate. Every digit in it is in one of the tickets it cites, and the pull request that added it lists the sentences the check cut, including one from an earlier pass.

— END · Field Notes —

Keep reading

All writing →
technical-deep-dive · Sep 21, 2026
Nº 01

What it took to ship BauGPT's Ausschreibungs-Radar

Most of the work in a tender radar is not the matching. It is everything around the matching that stops it from mailing the wrong company, at the wrong time...

6 MIN READ
↗
technical-deep-dive · Aug 31, 2026
Nº 02

The hard part of a tender radar is not the matching

Most of the work in shipping BauGPT's Ausschreibungs-Radar had nothing to do with matching. Deciding whether a public tender fits a Bau company's profile was...

7 MIN READ
↗
technical-deep-dive · Aug 20, 2026
Nº 03

What Bau Enterprises Actually Made Us Build

Most of what we shipped on BauGPT in the last weeks had nothing to do with the model. It was folders, permissions, single sign-on, and Word files that open...

7 MIN READ
↗
— THE NEWSLETTER

One note a week.
No fluff, just what works.

AI engineering, growth hacks, and messy lessons from shipping BauGPT. Unsubscribe anytime. I'll even miss you.

FIELD NOTES · NEXT ISSUE DROPS MONDAY
↳ No spam. One note weekly.