DEV Community

Thibault Leture
Thibault Leture

Posted on

How I stopped my LLM from inventing legal clauses

I built ConformeFR, a free generator of French legal pages: mentions légales (the legal notice every professional French website must publish) and GDPR privacy policies. It's a small Next.js app. The interesting part isn't the stack, it's the constraint:

In this domain, a sentence the model made up is a liability. A missing phone number in a French legal notice is a criminal offence (up to one year in prison and €75,000 for an individual, art. 1-2 LCEN). A privacy policy that says "you can refuse cookies in your browser settings" contradicts the French data protection authority (CNIL).

So the question became: how do you use an LLM where being wrong is expensive? Here is what I ended up with, including the part where I got it wrong first.

1. The law is code, not a prompt

The document structure is hand-written TypeScript. Each mandatory rule points to an article of law and has a test:

// Art. R526-27 Code de commerce: a sole trader's name is followed by "EI"
const nomAffiche =
  ei && !/\b(EI|entrepreneur individuel)\b/i.test(vars.nomEntreprise)
    ? `${nomEntreprise} EI`
    : nomEntreprise;
Enter fullscreen mode Exit fullscreen mode
it("micro-entrepreneur: name followed by « EI », no share capital", () => {
  const html = buildMentionsLegales({ ...BASE, nomEntreprise: "Jean Dupont",
    formeJuridique: "Auto-entrepreneur / Micro-entreprise", capitalSocial: "1000" });
  expect(html).toContain("Nom et prénom :</strong> Jean Dupont EI");
  expect(html).not.toContain("Capital social");
});
Enter fullscreen mode Exit fullscreen mode

53 tests cover the templates today. When a rule changes, the diff is reviewable and the test says why the line exists. You can't get that from a prompt.

2. The LLM writes one paragraph, and it's treated as untrusted input

Claude Haiku writes a single, non-normative paragraph: a plain-language explanation of why the site processes data. That's the one place where good wording helps and a slightly clumsy sentence harms no one.

Its output goes through the same pipeline as user input:

function toPlainText(text: string): string | undefined {
  const plain = text
    .split("\n")
    .filter((line) => !/^\s*#/.test(line))            // no markdown headings
    .map((line) => line.replace(/^\s*[-•]\s+/, ""))   // no bullet points
    .join(" ")
    .replace(/[*_`]+/g, "")
    .replace(/\s+/g, " ")
    .trim();
  return plain || undefined;
}
Enter fullscreen mode Exit fullscreen mode

Then it's HTML-escaped, like every field of the form. The call has a 15-second timeout and one retry, and if it fails the document is generated without the paragraph. The error is logged, not swallowed.

3. When the model cannot know, don't ask it

This is where I got it wrong first.

The first version asked Claude to "describe the activity" of the company for the legal notice. The only inputs were the company name, its legal form and the site type. For a test company called "AUDIT TEST", it confidently wrote that the business offered "audit and compliance services", with a stray markdown heading on top.

Nothing in the prompt was wrong. The model simply had no information, and filled the gap. No amount of prompt engineering fixes a missing input, so I removed the feature. The next version will read the site's real content and propose a description the user must validate, with the source shown.

4. What an audit found (and why I recommend doing one)

Before promoting the project, I ran a full audit: performance, accessibility, security, legal accuracy, and the purchase flow end to end. Honest list:

  • The login page returned a 500 on every direct load in production. Better Auth was in serverExternalPackages, so its React hook ran against a second copy of React during SSR (Cannot read properties of null (reading 'useRef')). Client-side navigation hid it. Fix: load those components with dynamic(..., { ssr: false }).
  • A stored XSS: form fields were interpolated into HTML rendered with dangerouslySetInnerHTML, on a shareable URL.
  • Marketing copy that claimed more than the product did, including a badge implying approval by the CNIL. That's not just wrong, it's a misleading commercial practice under French law. Gone.
  • Legal gaps in the templates: no hosting provider phone number, old article numbering (the 2024 "SREN" law renumbered the LCEN), cookie consent described the way the CNIL explicitly says is not enough.
  • Light-mode contrast below WCAG AA on the main button.

Everything is fixed now: axe reports 0 violations in light and dark mode, Lighthouse mobile performance is 98–100, and the tool is free with no sign-up.

Takeaways

  • Put the rules you can be sued for in code, with tests. Let the model do the parts where wording matters and errors are cheap.
  • Treat model output exactly like user input: sanitize, escape, time out, fall back.
  • If the model lacks the information, the fix is a better input, not a better prompt.
  • Audit before you promote. Mine found a production 500 I had never seen.

The code is MIT-licensed: github.com/TeeBo8/conforme. If you know French law better than I do, issues citing the article are the most useful contribution.

Top comments (0)