I have a small pipeline that turns a daily market report into four subtitle cards for a short video. It runs unattended every morning. I checked its output after two weeks and the wording never repeated once.
It still looked machine-generated. Here is what the opening card said on ten consecutive days:
the coin that pumped hardest, but volume is cold?
old coin FIL back on the gainers board?
small caps exploding, majors flat?
up 38% and nobody's buying?
the top gainer is the fakest one?
the coin that rose least is the popularity king
falling price but volume spiking, who's rotating out?
everything red, but this one is up
meme DeFi AI all pumping the same day?
DEX names all agreed to pump together?
Every word is different. Eleven out of fourteen are the same skeleton: a contrast, then a question mark. Read one, it's fine. Read a week, and you can see the machine.
Where it came from
The relevant line of my system prompt:
4. Card 1 is the hook (a cliffhanger or counterintuitive point),
cards 2-3 carry the concrete numbers, card 4 is a conclusion or a question.
"Cliffhanger." "Counterintuitive." I wrote those words to describe the goal, and the model read them as a description of the form. A counterintuitive point, rendered as a sentence, is "X but Y?" — so that is what came out, every single day.
The model was not being lazy. I put the cliché in the prompt and it did what I asked.
There is a second cause underneath: the model cannot see what it wrote yesterday. Each run is a fresh context. Even if the wording rule were perfect, nothing stops it from reaching for the same structure every morning, because from its point of view every morning is the first one.
Fix 1: stop describing the shape
Replace the adjective with a menu of options, and name the failure mode explicitly:
4. Card 1 is the opener, cards 2-3 carry the concrete numbers, card 4 concludes.
5. Card 1 must open differently every day. Angles to rotate through
(do not keep picking the same one):
- name a specific ticker and its number
- lead with a magnitude (volume, market cap, share of total)
- lead with a change over time (three days running, week to date)
- describe the state of a whole group or sector
- state a fact that is happening, with no evaluation
Do not write "A is up but B is..." or "...and nobody's buying?" every day.
A reader who scrolls past three of these should not be able to tell
they came from the same template.
Card 1 should not end in a question mark unless the content really is
an open question.
Two things matter here. Naming the bad pattern in the prompt works better than only describing the good one — the model needs something to steer away from. And listing angles rather than one instruction gives it somewhere to go.
Fix 2: show it what it already said
The structural half needs memory. Read back your own recent output and attach it to the request:
def recent_hooks(n=7):
"""Last n days of card 1, as an avoid-list. Returns '' on any failure."""
try:
seen = {} # date -> hook, so a same-day rerun overwrites
for line in open(LOG, encoding="utf-8"):
m = re.match(r"^\[(\d{4}-\d{2}-\d{2})T[^\]]*\] card1: (.+)$", line)
if m:
seen[m.group(1)] = m.group(2).strip()
hooks = list(seen.values())[-n:]
if not hooks:
return ""
return ("\n\nThese are the openers already used in the last "
f"{len(hooks)} days. Today's must be clearly different — "
"not just different words, a different sentence skeleton:\n"
+ "\n".join(f"- {h}" for h in hooks))
except Exception:
return ""
Then messages=[{"role": "user", "content": draft + recent_hooks()}].
Three details that are easy to get wrong:
Read from a log you are already writing. My first instinct was a new history.json that the pipeline would append to. That version would have been correct and useless — it starts empty, so the avoid-list does nothing for the first week. The daily log already had card1: ... in it going back a month, so the feature worked on the first run. I had made this exact mistake before on a different pipeline and shipped a de-duplication function that read from an empty directory and therefore returned an empty string forever.
Return empty on failure, never raise. A cosmetic feature must not be able to take down a job that runs while you sleep.
Key by date, not by line. A same-day rerun should replace that day's entry, not add a second one and push a real day off the end of the window.
How to know it worked
Run the old and new prompt against the same input. Same day's report, two runs:
| Card 1 | |
|---|---|
| Old | DEX names all agreed to pump together? |
| New | RAY up 41% in a day |
I added a --dry-cards flag to the production script that stops after the model call — no video render, no upload, no notification — so this comparison runs the code that actually ships rather than a copy of it I pasted into a scratch file. A copy would have proved something about the copy.
That flag introduced one more problem worth mentioning, because it is the kind of thing that bites three weeks later. The dry run writes its cards to the same log that recent_hooks() reads. Test output would have become "history" and been fed back to the model as something to avoid. So the dry mode tags its lines:
log(f"{'[dry] ' if dry_cards else ''}card{i+1}: {c}")
and the regex in recent_hooks() requires card1: to come straight after the timestamp, so [dry] card1: never matches. I checked both directions: the test line is in the file (grep finds it), and the parser returns zero rows for it. A guard you only tested in one direction is half-tested — I have shipped a filter that correctly blocked the bad case and silently ate a good one.
The general version
If your prompt contains an adjective describing the output, expect that adjective to show up in the output. "Punchy," "surprising," "counterintuitive," "engaging" — these read as instructions about tone, but they land as instructions about form.
And a stateless model cannot avoid repeating itself. Variety is not something you can ask for; it is something you have to give it the inputs for.
The tell is worth internalising: no repeated words, same repeated skeleton. Any per-token novelty check will pass that. You have to read a week of output in one sitting to see it, which is exactly what nobody does with a pipeline that has been quietly working for a month.
I write about running unattended automation and AI agents in production, including the parts where the agent — or I — confidently get it wrong. The full system I use for this is the Claude Code Automation Playbook.
Top comments (0)