WritingAI

A model's finding on compliance data without a record id is a guess

Gate every sentence a language model writes about compliance data on cited record ids and matching numbers, so a reviewer reads only what they can open.

A reviewer opens vendor 2231's audit questionnaire and types: "Does this vendor encrypt its backups, and for how long are they kept?" The service retrieves three answers from the questionnaire (ans_118 on data retention, ans_119 on sub-processors, ans_120 on penetration testing) and sends them to a language model with the question. The model replies: "Backups are encrypted at rest and retained for 30 days [ans_118]. The vendor holds ISO 27001 certification [ans_120]." The first sentence is true. The second is invented: nothing in the three answers mentions a certification of any kind.

My position is this: a sentence the model writes about compliance data is not a finding until a deterministic gate has confirmed that every record it cites was in the retrieved set for this request, and that every number, date and quoted phrase in the sentence appears in one of those records. A sentence that fails is dropped before the reviewer sees it, and logged with the reason. The reviewer never reads a sentence whose source they cannot open. I have built AI-powered NLP insights on compliance data, and this gate is the piece I would build before touching the prompt.

What the gate checks

The model is instructed to end every sentence with the ids of the records it drew on, in square brackets, and to copy numbers and dates exactly as they appear in the source. The gate splits the draft into sentences and runs four checks on each.

First, the sentence carries at least one id. Second, every id is in the set the retriever returned for this request, not merely a valid id somewhere in the database. Third, every number, date and currency amount in the sentence, normalised so that 30 days and 30-day match, occurs in the text of at least one cited record. Fourth, every phrase in quotation marks occurs verbatim in a cited record.

flowchart TD
  Q[Reviewer question] --> R[Retriever]
  R --> S[Retrieved set ids]
  R --> M[Model draft]
  S --> G[Citation gate]:::accent
  M --> G
  G --> V[Reviewer view with sources]
  G --> L[Dropped sentence log]
The gate takes the retrieved ids and the draft as separate inputs and splits the draft into two outputs

Walk the example through. Sentence one cites ans_118, which is in the retrieved set, and 30 occurs in its text, so it passes. Sentence two cites ans_120, which exists and was retrieved, so the first two checks pass. The third fails: 27001 appears nowhere in ans_120, whose text describes a penetration test dated March 2025. The sentence is dropped, and the log records the sentence, the cited id and the reason "number 27001 not in ans_120". The reviewer's screen shows one sentence, with a link that opens ans_118 beside it. Without the gate they would have read a certification claim with a plausible citation, closed the review on it, and the audit record would now carry a certification the vendor never claimed.

TypeScript
// simplified: one sentence in, one verdict out
type Row = { id: string; text: string };

function check(sentence: string, retrieved: Map<string, Row>) {
  const ids = [...sentence.matchAll(/\[([a-z]+_\d+)\]/g)].map(m => m[1]);
  if (ids.length === 0) return { ok: false, reason: 'no citation' };
  const rows = ids.map(id => retrieved.get(id));
  if (rows.some(r => !r)) return { ok: false, reason: 'id not retrieved' };
  const body = rows.map(r => r!.text).join('\n').replace(/,/g, '');
  const plain = sentence.replace(/\[[a-z]+_\d+\]/g, '');
  for (const m of plain.matchAll(/\d[\d,.]*\d|\d/g)) {
    const n = m[0].replace(/,/g, '');
    if (!body.includes(n)) {
      return { ok: false, reason: `number ${n} not in ${ids.join(' ')}` };
    }
  }
  return { ok: true, ids };
}

Where the gate cannot see

Checks on ids and numbers catch invented facts and misattributed figures, which are the failures that end up in an audit record. They do not catch a flipped negation. If ans_118 reads "backups are not encrypted at rest" and the model writes "backups are encrypted at rest [ans_118]", every check passes: the id was retrieved and there is no number to match. Two things contain this.

The reviewer view renders the cited record's text beside each sentence, so the source is one glance away rather than one click. And for any questionnaire field whose stored answer is yes or no, the gate asks the model for the field key and a value instead of prose, compares the value with the stored one, and drops a mismatch. Prose is reserved for fields that have no structured answer.

The drop rate is itself a signal. When more than a third of a draft's sentences are dropped, return "not enough evidence in the questionnaire" instead of the surviving fragment, because a fragment reads as an answer to a question it no longer answers. A reviewer who sees one sentence about retention when they asked about encryption will assume the encryption answer was no.

Build the gate before you tune the prompt. Store the retrieved set under the request id, log every dropped sentence with its reason, and read that log for a week: the reasons tell you whether the retriever is missing records or the model is inventing them, and those are different fixes.

Written by Md Nasim Anjum, senior full-stack engineer in Manchester. He builds payment orchestration, KYC and KYB compliance platforms and conversational AI.

Get in touchAll writingRSS

More writing