DEV Community

Cover image for Your prompt said no em dashes. The model used one anyway.
Sébastien Doom
Sébastien Doom

Posted on

Your prompt said no em dashes. The model used one anyway.

Every guide about making AI-written copy sound human is a list of prompt rules. Ban the clichés. Ban the em dash. Ask for a real voice, a real register, a real person.

I wrote those rules. They are in production. They work most of the time.

Most of the time is the problem. A rule that holds nine times out of ten is not a rule, it is a tendency, and the tenth output is the one a reader notices. You cannot ship a tendency.

So the prompt became layer one of four. The other three are deterministic code that runs after the model has already answered. This is the whole pipeline, including the ordering decision that turned out to matter more than any individual rule.

The four-layer generation pipeline: prompt, sanitize, lint, signature. Lint and signature failures loop back to regenerate.

Why the em dash is the canary

Of all the AI tells, the em dash is the one readers have been trained on fastest. It is also the easiest to reason about, because it is mechanically detectable: a single codepoint, no ambiguity, no natural-language parsing required.

That combination makes it the perfect test case for the real question, which is not "how do I remove em dashes" but "what do I do about a rule the model agrees with and then breaks anyway?"

Layer 1: the prompt, necessary and insufficient

This is the ruleset injected into every generative path of the product:

export const ANTI_SLOP_RULES = [
  "Anti-slop rules. The output must read like a real developer wrote it, never like generic AI copy:",
  "- Ban these cliches and close variants: \"passionate developer\", \"love to code\", \"self-taught\", \"problem solver\", \"team player\", \"detail-oriented\", \"hardworking\", \"motivated developer\".",
  "- Ban filler buzzwords: leverage, synergy, results-driven, seasoned, guru, ninja, rockstar, \"wear many hats\", \"think outside the box\", \"fast-paced environment\".",
  "- Ban AI-tell phrasing: \"it's not just X, it's Y\", \"in today's ... world\", \"whether you're ... or ...\", \"delve\", \"tapestry\", \"testament to\", \"embark on a journey\", and gratuitous em dashes.",
  "- No hype-stacking adjectives like \"highly skilled and dedicated\". One concrete specific beats three vague superlatives.",
  "- Lead with what they actually build and the stack they use, not feelings about code or mission statements.",
  "- Keep their authentic voice and register. Never inflate a plain bio into LinkedIn-speak, and never invent facts they did not provide.",
  "- No emoji unless the original already used them, and no exclamation-mark spam.",
].join("\n")
Enter fullscreen mode Exit fullscreen mode

One detail in there is deliberate and worth stealing: the prompt itself contains no em dashes. A ruleset that bans a character while using it is teaching by counterexample, and the model reads the whole prompt as one sample of the voice you want. If you ask for plain prose in ornate prose, you get ornate prose.

This layer removes most of the slop. It never removes all of it.

Layer 2: a fixer that cannot fail

Anything mechanically fixable should never reach a human, and should never cost a retry. That is this function:

// Only the figure/en/em/horizontal-bar dashes (U+2012, U+2013, U+2014, U+2015).
// Regular hyphens (U+002D, e.g. "full-stack") are never touched. The class
// lists the four characters explicitly rather than using a range, so it stays
// readable and cannot silently widen.
const SLOP_DASHES = /[‒–—―]/

export function stripEmDashes(input: string): string {
  if (!input || !SLOP_DASHES.test(input))
    return input

  return input
    // Numeric ranges ("2020 – 2024", "40%—50%") read as a hyphen, not a comma.
    .replace(/(\d%?)\s*[‒–—―]\s*(\d)/g, "$1-$2")
    // Any remaining dash becomes a comma + space, the way a developer
    // would actually write it.
    .replace(/\s*[‒–—―]\s*/g, ", ")
    // Tidy the artifacts the swap creates.
    .replace(/,\s*,/g, ",")
    .replace(/,\s*([.;:!?])/g, "$1")
    .replace(/\s+([,.;:!?])/g, "$1")
    .replace(/ {2,}/g, " ")
    .trim()
}
Enter fullscreen mode Exit fullscreen mode

Three things in there each cost me a bug before they existed.

Real hyphens are sacred. The naive version is /[-–—]/, and it turns full-stack developer into full, stack developer. The character class lists four typographic dashes explicitly and U+002D is not one of them.

A dash between digits is a range, not a clause break. 2020 – 2024 becoming 2020, 2024 changes the meaning of a CV line. That rule has to run first, before the general case eats it.

The swap leaves debris. Replacing a dash with ", " produces React, , Go and solving DX problems, . and double spaces. The four cleanup passes at the end exist entirely because of what the two passes above create.

The test file reads like a list of the mistakes:

expect(stripEmDashes("TypeScript, Next.js, Firebase — cut build times 40%"))
  .toBe("TypeScript, Next.js, Firebase, cut build times 40%")

expect(stripEmDashes("2020 – 2024")).toBe("2020-2024")
expect(stripEmDashes("40%—50%")).toBe("40%-50%")

expect(stripEmDashes("full-stack developer")).toBe("full-stack developer")

expect(stripEmDashes("solving DX problems — .")).toBe("solving DX problems.")
expect(stripEmDashes("React, — Go")).toBe("React, Go")
expect(stripEmDashes("A — B — C")).toBe("A, B, C")
Enter fullscreen mode Exit fullscreen mode

Layer 3: the linter, for tells you cannot mechanically fix

An em dash has a mechanical replacement. "Here's the thing" does not. You cannot delete it and keep a sentence, because the sentence was built to lean on it. Those tells need an actual rewrite, which means another model call.

const BANNED_PATTERNS: BannedPattern[] = [
  { id: "em-dash",            test: /—/,                        message: "em dash" },
  { id: "heres-the-thing",    test: /here['’]s the thing/i,     message: "\"Here's the thing\"" },
  { id: "let-me-explain",     test: /let me explain/i,          message: "\"Let me explain\"" },
  { id: "that-said",          test: /\bthat said\b/i,           message: "\"That said\"" },
  { id: "the-truth-is",       test: /the truth is\b/i,          message: "\"The truth is\"" },
  { id: "turns-out",          test: /\bturns out\b/i,           message: "\"(it) turns out\"" },
  { id: "plot-twist",         test: /plot twist:/i,             message: "\"Plot twist:\"" },
  { id: "ellipsis",           test: /\.\.\./,                   message: "triple-dot ellipsis" },
  { id: "excited-to-announce",test: /excited to announce/i,     message: "\"excited to announce\"" },
  { id: "game-changer",       test: /game[\s-]?changer/i,       message: "\"game-changer\"" },
  { id: "revolutionize",      test: /revolutioni[sz]e/i,        message: "\"revolutionize\"" },
  { id: "sound-familiar",     test: /sound familiar\?/i,        message: "\"sound familiar?\"" },
  { id: "imagine-this",       test: /(imagine|picture) this:/i, message: "\"imagine/picture this:\"" },
  // ~25 entries in total
]
Enter fullscreen mode Exit fullscreen mode

The interesting entry is not in that list, because it is not a phrase. It is a shape:

// Parallel-3 list ("a, b, and c"). One is acceptable; flagged only when the
// shape is leaned on 2+ times in the same post.
const PARALLEL_3 = /\w+,\s+\w+,?\s+and\s+\w+/gi

if ((text.match(PARALLEL_3) ?? []).length >= 2)
  violations.push("parallel-3 list pattern used more than once")
Enter fullscreen mode Exit fullscreen mode

Models love the tricolon. One in a post is good writing. Two in a post is a rhythm, and rhythm is what makes a paragraph feel generated even when every individual sentence is fine. Note the threshold: banning it outright would fight the model on something humans do too.

The ordering decision that mattered most

Here is the part I got wrong first, and the reason I am writing this at all.

I originally ran the linter directly on the model output. The linter bans em dashes, the model kept producing them, so every relapse burned one of my two retries. I was paying a full model call to fix a character substitution.

The fix was to swap two boxes in the pipeline: sanitize before you lint.

Wrong order: lint runs on the raw output, so an em dash costs a full model call. Right order: sanitize runs first for free, so the retry is spent on a cliché that genuinely needs a rewrite.

The comment I left on the sanitizer says it better than I can paraphrase:

These tells are mechanically fixable, so strip them BEFORE linting: a relapse no longer fails the draft, and the retry budget is reserved for genuine judgment issues (clichés, the parallel-3 list shape) that actually need a rewrite.

Same rules, same budget, and suddenly the retries were being spent on problems that actually required a different draft.

Layer 4: individually fine, collectively robotic

This is the layer almost nobody builds, and the one that changed my output the most.

A single post can pass every check above and still be obviously automated, because it is the fourth post in a row that opens with the same word, runs three paragraphs, and has a twelve-word first sentence. Nothing in that post is wrong. The feed is wrong.

So every draft gets a structural fingerprint, compared against recent posts on the same channel:

export interface Signature {
  firstSentenceLength: number
  paragraphCount: number
  openingWord: string
  avgSentenceLength: number
}

// How close two values must be on each axis to count as a "match".
const FIRST_SENTENCE_TOLERANCE = 3
const AVG_SENTENCE_TOLERANCE = 2
// Number of matching axes (out of 4) that flags two posts as too similar.
const SIMILARITY_AXIS_THRESHOLD = 2
Enter fullscreen mode Exit fullscreen mode

Two out of four axes is enough to reject. In practice that looks like this:

Axis Last week's post New draft Match?
Opening word shipped shipped yes
First sentence length 11 words 13 words yes (within 3)
Paragraph count 4 6 no
Avg sentence length 14 19 no

Two matches, so the draft is rejected and regenerated with an instruction naming exactly what to change: "Change the opening sentence, paragraph count, and rhythm."

Crude? Completely. Four integers and two tolerance constants. It still catches the thing that no per-post check can see, which is that you are publishing the same skeleton with different words in it.

Wiring it together: a retry budget with escalating notes

const ZOD_RETRIES = 2
const LINT_RETRIES = 2
const SIGNATURE_RETRIES = 1

// Each loop tightens the prompt with the reason the previous attempt failed.
while (retries <= ZOD_RETRIES + LINT_RETRIES + SIGNATURE_RETRIES) {
  const userPrompt = buildPrompt(channel, { /* ... */ retryNote })
  const output = await callClaude(system, userPrompt)

  // 1. schema
  if (!validation.ok) {
    retryNote = `Your previous attempt did not match the OUTPUT CONTRACT (${validation.issues.join("; ")}). Return valid JSON matching the contract exactly.`
    continue
  }

  // 2. banned patterns
  const lint = lintOutput(channel, output)
  if (lint.violations.length > 0 && lintFails < LINT_RETRIES) {
    retryNote = `Your previous attempt used banned AI-tell patterns: ${lint.violations.join("; ")}. Rewrite without any of them.`
    continue
  }

  // 3. structural sameness
  const sim = detectSimilarity(serializeOutput(channel, output), recentTexts)
  if (sim.tooSimilar && sigFails < SIGNATURE_RETRIES) {
    retryNote = `Your previous attempt was structurally too similar to recent posts (matched on: ${sim.matchedAxes.join(", ")}). Change the opening sentence, paragraph count, and rhythm.`
    continue
  }

  return { ok: true, output, meta: { retries, violations: lint.violations } }
}

return { ok: false, error: "Could not produce clean output within retry budget" }
Enter fullscreen mode Exit fullscreen mode

Three things worth copying from this loop:

The retry note names the specific failure. Not "try again, avoid AI tells" but the actual strings that fired. A model given "Here's the thing"; parallel-3 list pattern used more than once fixes those two things. A model given "be less generic" rewrites at random.

The budgets are separate. A draft that fails schema validation twice and then trips the linter still gets its linter retries. One shared counter would let a bad JSON day eat the quality checks.

Failure is a real outcome. When the budget runs out, the pipeline returns an error and the violations, not the least-bad draft. A human sees "could not produce clean output" and writes it themselves, which is the correct escalation.

What this does not do

I would rather say this than have you discover it.

It does not make the writing good. Every layer here is subtractive. Remove the tells, and you are left with copy that no longer announces that a model wrote it. Whether it is worth reading is entirely a question of what you put in the prompt.

A banned list is a blacklist, and blacklists rot. Each model generation arrives with new favourites. Mine caught delve and tapestry early because everyone was complaining about them; the current crop of tells will need a different list in a year. Budget for maintenance.

The cliché list lives in two places. The prompt rules and the deterministic review checks both enumerate the same clichés, so the model is never rewriting text into a phrase its own reviewer would flag. Today they are kept in sync by a code comment that says "keep these in sync", which is a social contract, not a test. A shared constant with a test asserting both consume it is the actual answer, and it is on my list.

I have two copies of the sanitizer. One per app in the monorepo. They have already drifted: one collapses ... into a period, the other does not. That is what duplicated "small utility" code does, every time, and it is a better argument for a shared lib than any style guide.

The four things I would keep

  1. A prompt rule is a request. Code is a guarantee. Anything mechanically checkable belongs after generation, not only inside the prompt.
  2. Split tells into mechanical and judgment. Fix the mechanical ones silently and for free. Spend model calls only on the ones that genuinely need a different draft.
  3. Fix before you check. The single highest-leverage change in this whole pipeline was reordering two function calls.
  4. Check structure, not only words. The tell that survives every word-level filter is the shape of the thing.

Top comments (0)