← Volver al blog
steply / blog · anonimizacao-de-dados-com-ia-tutorial-passo-a-passo-com-claude.md
$ steply blog open anonimizacao-de-dados-com-ia-tutorial-passo-a-passo-com-claude
▸ loading article…
✓ ready
Anonimização de dados com IA: tutorial passo a passo usando o Claude

Anonimização de dados com IA: tutorial passo a passo usando o Claude

porSteply19 min de lectura

Todos os dados pessoais do exemplo são fictícios: CPF, CNPJ e cartão de teste.

Toda empresa que começou a usar IA passou pela mesma cena. Alguém do suporte copia um chamado, cola no chat e pede um resumo. O resumo chega em dez segundos, bem escrito. Junto com a pergunta, saíram da empresa o nome da cliente, o CPF, o número do cartão, o endereço e, no meio do texto, uma informação de saúde.

Ninguém fez isso por descuido. Fez porque funciona e porque ninguém entregou um jeito melhor. Este tutorial é esse jeito: um pipeline pequeno, em TypeScript, que tira os dados pessoais do texto antes de ele chegar à IA e devolve os dados no lugar certo depois que a resposta volta. Usamos o Claude em duas frentes: pela API, como detector de dado pessoal que regex não pega, e pelo Claude Code, para escrever e testar o próprio anonimizador sem nunca encostar em dado real.

O que vai junto quando alguém cola um chamado na IA

o que vai junto

Este é o chamado que vamos usar do começo ao fim:

Oi, aqui é a Mariana Albuquerque, CPF 529.982.247-25.
Meu cartão 4111 1111 1111 1111 foi cobrado duas vezes no pedido 88213.
Moro na Rua das Acácias, 120, apto 34, CEP 01310-100. Pode ligar no (11) 98765-4321
ou mandar para mariana.alb@email.com. Estou com pressa porque faço hemodiálise
toda terça e quarta. A empresa é a Albuquerque Doces, CNPJ 11.222.333/0001-81.

São oito dados que identificam uma pessoa, e um deles, a hemodiálise, é dado sensível pela LGPD. Repare no que não é dado pessoal: o número do pedido. Ele não aponta para ninguém sozinho, e o suporte precisa dele para resolver o problema. Anonimizar bem não é apagar tudo que tem número. É tirar o que identifica e manter o que dá contexto.

Antes do código: anonimizar não é pseudonimizar

anonimizar x pseudonimizar

A LGPD separa três situações, e vale entender antes de escrever uma linha:

  • Dado pessoal é o que identifica ou torna identificável uma pessoa (art. 5º, I). Saúde, religião, biometria e afins são sensíveis (art. 5º, II).
  • Pseudonimização troca o dado por um código e guarda a tabela de volta em lugar separado (art. 13, §4º). Para quem tem a tabela, continua sendo dado pessoal.
  • Anonimização é quando não dá mais para voltar com meios razoáveis. Aí o dado deixa de ser pessoal (art. 12).

Para atendimento, precisamos voltar: o atendente tem que responder a Mariana, não a "[PESSOA_1]". Então o que vamos construir é pseudonimização com cofre local. Para o Claude, o texto é anônimo, porque ele nunca vê a tabela. Para o seu time, a resposta volta com o nome certo.

O desenho: duas camadas, um cofre e uma trava

arquitetura

Regex é barata, exata e roda na sua máquina, mas não entende contexto: não sabe que "Mariana" é gente e "Acácias" é rua. IA entende contexto, mas não precisa ver o que a regex já sabe esconder. Então cada um faz o que faz melhor:

  1. Camada 1, regex local. CPF, CNPJ, cartão, e-mail, telefone e CEP saem aqui.
  2. Cofre. Cada dado vira um token com tipo, como [CPF_1]. O mapa fica com você.
  3. Camada 2, Claude. Recebe o texto já sem os identificadores diretos e aponta o que sobrou: nomes, endereço, saúde, identificador indireto.
  4. Trava. Se qualquer valor detectado ainda aparece no texto, o pipeline para.
  5. Uso e volta. A IA trabalha só com tokens; a reidentificação acontece na tela de quem pode ver.

Para seguir junto, você precisa de Node 23.6 ou mais novo, que roda TypeScript direto, sem build. A estrutura do projeto é esta (o código completo de cada arquivo está no fim do post):

anonimizacao/
├── package.json
├── src/
│ ├── camada1-regex.ts # passo 1
│ ├── cofre.ts # passo 2
│ ├── camada2-claude.ts # passo 3
│ ├── anonimizar.ts # orquestra e trava
│ └── demo.ts
└── test/
 └── anonimizacao.test.ts
{
 "name": "anonimizacao-com-claude",
 "private": true,
 "type": "module",
 "scripts": {
 "demo": "node src/demo.ts",
 "demo:local": "node src/demo.ts --sem-claude",
 "test": "node --test \"test/*.test.ts\""
 },
 "dependencies": {
 "@anthropic-ai/sdk": "^0.128.0",
 "zod": "^4.6.5"
 }
}
npm install
npm test # 6 testes, não chama a API
npm run demo:local # só a camada 1, sem chave
export ANTHROPIC_API_KEY=sk-ant-...
npm run demo # pipeline completo

Passo 1: o que tem formato fixo sai antes de tudo

camada 1

A primeira camada é regex, mas regex com validação. Sem o dígito verificador, qualquer sequência de onze números vira "CPF": número de protocolo, nota fiscal, pedido. O texto fica cheio de token e a IA perde o contexto de que precisava para ajudar.

// src/camada1-regex.ts
function cpfValido(cpf: string): boolean {
 const d = cpf.replace(/\D/g, "");
 if (d.length !== 11 || /^(\d)\1{10}$/.test(d)) return false;
 const dv = (base: string, pesoInicial: number) => {
 let soma = 0;
 for (let i = 0; i < base.length; i++) soma += Number(base[i]) * (pesoInicial - i);
 const resto = (soma * 10) % 11;
 return resto === 10 ? 0 : resto;
 };
 return dv(d.slice(0, 9), 10) === Number(d[9]) && dv(d.slice(0, 10), 11) === Number(d[10]);
}

O cartão usa o algoritmo de Luhn, o mesmo que a bandeira usa. O CNPJ tem a sua própria conta de dígito. E a ordem das regras importa: cartão e CNPJ vêm antes do CPF, senão um pedaço do número maior é capturado pela regra menor.

const REGRAS: Regra[] = [
 { tipo: "EMAIL", re: /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g },
 { tipo: "CARTAO", re: /\b(?:\d[ .-]?){12,18}\d\b/g, valida: luhnValido },
 { tipo: "CNPJ", re: /\b\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}\b/g, valida: cnpjValido },
 { tipo: "CPF", re: /\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/g, valida: cpfValido },
 { tipo: "TELEFONE", re: /(?:\+?55\s?)?\(?\b\d{2}\)?\s?9?\d{4}[-\s]?\d{4}\b/g },
 { tipo: "CEP", re: /\b\d{5}-\d{3}\b/g },
];

Rodando só essa camada, sem chave de API nenhuma:

rodando a camada 1

Seis dados saíram sem nada deixar a máquina. Mas olhe o que ficou: o nome da Mariana, o endereço por extenso e a hemodiálise. Regex não enxerga isso. É para esse resto que existe a segunda camada.

Passo 2: um token por pessoa, e o mapa fica em casa

cofre

O cofre troca cada valor por um token e guarda a volta. Três decisões fazem diferença:

Token com tipo. [PESSOA_1] em vez de XXXX. A IA continua sabendo o que estava ali, só não sabe quem. Um resumo feito sobre "cliente [PESSOA_1] com cobrança duplicada no cartão [CARTAO_1]" faz sentido. Um resumo sobre "XXXX com cobrança no XXXX" não faz.

Token estável. Se a Mariana aparece três vezes, ela vira [PESSOA_1] nas três. Sem isso a IA entende que são três clientes diferentes.

O cofre é o dado. O mapa token para valor é dado pessoal. Ele merece o mesmo cuidado do banco de produção: criptografado, com prazo de descarte e acesso por perfil. Nunca no mesmo lugar dos logs.

// src/cofre.ts
tokenPara({ tipo, valor }: Achado): string {
 const chave = `${tipo}:${valor.toLowerCase()}`;
 const existente = this.porValor.get(chave);
 if (existente) return existente;
 const n = (this.contadores.get(tipo) ?? 0) + 1;
 this.contadores.set(tipo, n);
 const token = `[${tipo}_${n}]`;
 this.porValor.set(chave, token);
 this.porToken.set(token, valor);
 return token;
}

reidentificar(texto: string): string {
 return texto.replace(/\[[A-Z_]+_\d+\]/g, (t) => this.porToken.get(t) ?? t);
}

Passo 3: o Claude aponta, o seu código troca

camada 2 com Claude

Aqui entra a IA. O detalhe mais importante do tutorial inteiro está nesta frase: o Claude não devolve o texto reescrito. Ele devolve a lista do que achou, e quem troca é o nosso código. Se você pede para a IA "reescrever o texto sem dados pessoais", ela pode corrigir uma vírgula, resumir um trecho, mudar uma data. Pedindo só a lista, o texto original fica intacto e a troca é exata.

Para a lista vir sempre no formato certo, usamos saída estruturada: um schema Zod passado em output_config.format. A resposta já chega validada, sem precisar pedir "responda só em JSON" e torcer.

// src/camada2-claude.ts
const Entidades = z.object({
 entidades: z.array(
 z.object({
 tipo: z.enum(["PESSOA", "ENDERECO", "SAUDE", "DATA_NASCIMENTO", "IDENTIFICADOR_INDIRETO"]),
 valor: z.string(),
 }),
 ),
});

export async function detectarPorContexto(texto: string): Promise<Achado[]> {
 const resposta = await client.beta.messages.parse({
 model: "claude-opus-5",
 max_tokens: 16000,
 output_config: { effort: "low", format: betaZodOutputFormat(Entidades) },
 betas: ["server-side-fallback-2026-07-01"],
 fallbacks: "default",
 system: INSTRUCOES,
 messages: [{ role: "user", content: texto }],
 });

 if (resposta.stop_reason === "refusal") {
 throw new Error("Claude recusou a análise; o texto não deve seguir adiante sem revisão.");
 }
 if (!resposta.parsed_output) {
 throw new Error(`resposta sem JSON válido (stop_reason: ${resposta.stop_reason})`);
 }
 return resposta.parsed_output.entidades.filter((e) => texto.includes(e.valor));
}

Quatro escolhas nesse trecho merecem explicação:

  • effort: "low". Detectar entidade é tarefa de leitura, não de raciocínio longo. Esforço baixo deixa a chamada mais rápida e mais barata sem perder qualidade aqui.
  • fallbacks: "default". Se o modelo principal recusar o pedido, a própria API tenta de novo em um modelo reserva, na mesma chamada.
  • Recusa ou JSON inválido param o pipeline. Nunca "segue sem a camada 2". Texto que não passou pela análise não sai.
  • O filtro final. Só aceitamos trechos que existem de verdade no texto. Se a IA devolver um nome com grafia corrigida, ele é descartado em vez de gerar uma troca errada.

O prompt de sistema tem um viés proposital: na dúvida, inclua. Mascarar a mais custa um token a mais no texto. Deixar passar custa um incidente.

Você é um detector de dados pessoais (LGPD) em textos em português.
Liste cada trecho que identifica ou torna identificável uma pessoa natural:
- PESSOA: nome ou apelido de pessoa (não de empresa, produto ou cidade).
- ENDERECO: rua, número, complemento, bairro quando vier junto de um endereço.
- SAUDE: diagnóstico, doença, medicamento, exame ou condição de saúde.
- DATA_NASCIMENTO: data de nascimento ou idade exata junto de outro dado.
- IDENTIFICADOR_INDIRETO: descrição que aponta para uma pessoa só.
Copie o trecho exatamente como está no texto, sem corrigir grafia.
Tokens entre colchetes como [CPF_1] já foram anonimizados: ignore.
Na dúvida, inclua.

Uma observação honesta: na camada 2, o nome e o endereço ainda chegam ao Claude, porque é justamente isso que ele precisa ler para apontar. O que a arquitetura garante é que CPF, cartão, e-mail, telefone e documento nunca saem, e que a tarefa final (resumir, classificar, responder) é feita sobre texto só com tokens. Para dado de saúde em volume, confira no contrato com o provedor as condições de retenção e de uso dos dados, e se a sua operação exige processamento em região específica.

Juntando tudo

pipeline completo

O orquestrador tem quinze linhas. A ordem é o que importa: regex primeiro, IA depois, trava no fim.

// src/anonimizar.ts
export async function anonimizar(texto: string, cofre: Cofre, contexto?: Detector) {
 const porPadrao = detectarPorPadrao(texto);
 const etapa1 = cofre.substituir(texto, porPadrao);

 const porContexto = contexto ? await contexto(etapa1) : [];
 const final = cofre.substituir(etapa1, porContexto);

 return { texto: final, achados: [...porPadrao, ...porContexto] };
}

Depois disso, o texto anonimizado pode ir para qualquer tarefa: resumo, classificação, resposta sugerida. No exemplo, pedimos ao Claude um resumo com próxima ação e a instrução de manter os tokens como estão. A resposta volta com [PESSOA_1] e [CARTAO_1], e só na tela do atendente o cofre.reidentificar() coloca o nome e o cartão de volta. O Claude resolveu o chamado sem saber de quem era. (O texto do resumo na imagem é um exemplo: a redação do Claude muda a cada execução, os tokens não.)

Passo 4: vazamento zero é regra de parada, não meta

trava e testes

Todo pipeline de anonimização precisa de uma última barreira que não confia em ninguém, nem na regex nem na IA. A nossa é simples: se qualquer valor detectado ainda aparece no texto final, lança erro e nada é enviado.

export function garantirSemVazamento(saida: string, achados: Achado[]): void {
 const vazou = achados.filter((a) => saida.includes(a.valor));
 if (vazou.length) {
 throw new Error(`vazamento: ${vazou.map((a) => a.tipo).join(", ")}`);
 }
}

E os testes cobrem os dois lados: o que deve ser pego (CPF válido, cartão que passa no Luhn) e o que não deve (um número qualquer com cara de CPF, um CNPJ confundido com CPF). O quinto teste mostra um truque útil: a camada 2 entra como uma função falsa. A lógica de troca e a trava são testadas sem gastar chamada de API e sem depender de rede.

test("detector de contexto falso: nome e saúde saem, nada vaza", async () => {
 const falsoClaude = async () => [
 { tipo: "PESSOA", valor: "Mariana" },
 { tipo: "SAUDE", valor: "hemodiálise" },
 ];
 const cofre = new Cofre();
 const { texto, achados } = await anonimizar("Mariana (mariana@x.com) faz hemodiálise.", cofre, falsoClaude);
 assert.equal(texto, "[PESSOA_1] ([EMAIL_1]) faz [SAUDE_1].");
 garantirSemVazamento(texto, achados);
});

Passo 5: deixe o Claude Code escrever o anonimizador sem ver o dado real

Claude Code

A segunda forma de usar o Claude nesse trabalho é como par de programação. O Claude Code lê o repositório, escreve o detector novo, gera os testes e roda até passar. O cuidado é garantir que, nesse processo, ele nunca abra a pasta com dados reais.

Isso se resolve em dois arquivos. O primeiro é o .claude/settings.json do projeto, que bloqueia a leitura: não é pedido, é regra.

{
 "permissions": {
 "deny": [
 "Read(./dados-reais/**)",
 "Bash(psql:*)",
 "Read(./.env*)"
 ]
 }
}

O segundo é o CLAUDE.md, que dá as regras da casa para cada sessão:

## Dados pessoais
- Nunca use dado real em teste ou exemplo.
- Gere CPF, CNPJ e cartão sintéticos com dígito válido.
- Todo detector novo entra com teste de falso positivo e de vazamento.

Com isso, um pedido como o da imagem (uma sessão ilustrativa, para mostrar o ciclo), "crie um detector de placa de veículo, gere exemplos sintéticos e falsos positivos, e teste os dois lados" vira um ciclo completo: o Claude lê o código atual, escreve a regra, escreve o teste, roda, encontra o falso positivo, aperta a regra e roda de novo. Você revisa o diff. O dado real não saiu da pasta dele.

Antes de colocar em produção

checklist

Faça:

  • Regex antes da IA. Tudo que tem formato fixo nunca sai da máquina.
  • Valide o dígito. CPF, CNPJ e cartão com verificação, não só formato.
  • Token com tipo e estável, sempre o mesmo para a mesma pessoa.
  • Trave o pipeline quando sobrar qualquer valor detectado.
  • Revise o contrato com o provedor de IA: retenção, uso para treino e região de processamento.

Não faça:

  • Não peça à IA para devolver o texto reescrito. Peça a lista e troque você.
  • Não guarde o cofre junto dos logs. Ele é o dado pessoal que você tirou do texto.
  • Não teste com dado real. Gere sintético com dígito válido.
  • Não chame de "anônimo" o que você consegue reverter. É pseudônimo, e a lei trata diferente.
  • Não trate como 100%. Amostre saídas toda semana e meça o que escapou.

O ponto

A pergunta nunca foi "posso usar IA com dado de cliente". Foi "como uso IA sem entregar o dado do cliente junto". A resposta não depende de um produto caro nem de proibir o time de usar a ferramenta. Depende de uma camada pequena, testada, que fica entre a pessoa e o modelo e faz sempre a mesma coisa: tira o que identifica, deixa o que dá contexto, trava quando algo escapa e devolve o nome só para quem pode ver.

O código deste tutorial tem menos de trezentas linhas. Cabe em uma tarde. O incidente que ele evita não cabe em um trimestre.

Código completo

Os seis arquivos, na íntegra. Copie, rode npm test e adapte ao seu caso.

src/camada1-regex.ts

// Camada 1: detecção determinística, 100% local.
// Tudo que tem formato fixo (CPF, CNPJ, e-mail, telefone, CEP, cartão) sai aqui,
// antes de qualquer texto sair da máquina.

export type Achado = { tipo: string; valor: string };

// Dígito verificador: sem ele, qualquer sequência de 11 números vira "CPF"
// e o texto fica cheio de falso positivo (número de pedido, protocolo...).
function cpfValido(cpf: string): boolean {
 const d = cpf.replace(/\D/g, "");
 if (d.length !== 11 || /^(\d)\1{10}$/.test(d)) return false;
 const dv = (base: string, pesoInicial: number) => {
 let soma = 0;
 for (let i = 0; i < base.length; i++) soma += Number(base[i]) * (pesoInicial - i);
 const resto = (soma * 10) % 11;
 return resto === 10 ? 0 : resto;
 };
 return dv(d.slice(0, 9), 10) === Number(d[9]) && dv(d.slice(0, 10), 11) === Number(d[10]);
}

function cnpjValido(cnpj: string): boolean {
 const d = cnpj.replace(/\D/g, "");
 if (d.length !== 14 || /^(\d)\1{13}$/.test(d)) return false;
 const dv = (base: string) => {
 const pesos = base.length === 12 ? [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] : [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
 const soma = pesos.reduce((acc, p, i) => acc + Number(base[i]) * p, 0);
 const resto = soma % 11;
 return resto < 2 ? 0 : 11 - resto;
 };
 return dv(d.slice(0, 12)) === Number(d[12]) && dv(d.slice(0, 13)) === Number(d[13]);
}

// Luhn: o mesmo filtro que a bandeira usa para validar número de cartão.
function luhnValido(numero: string): boolean {
 const d = numero.replace(/\D/g, "");
 if (d.length < 13 || d.length > 19) return false;
 let soma = 0;
 for (let i = 0; i < d.length; i++) {
 let n = Number(d[d.length - 1 - i]);
 if (i % 2 === 1) { n *= 2; if (n > 9) n -= 9; }
 soma += n;
 }
 return soma % 10 === 0;
}

type Regra = { tipo: string; re: RegExp; valida?: (v: string) => boolean };

// A ordem importa: cartão e CNPJ antes de CPF e telefone, senão um pedaço
// de um número maior é capturado pela regra menor.
const REGRAS: Regra[] = [
 { tipo: "EMAIL", re: /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g },
 { tipo: "CARTAO", re: /\b(?:\d[ .-]?){12,18}\d\b/g, valida: luhnValido },
 { tipo: "CNPJ", re: /\b\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}\b/g, valida: cnpjValido },
 { tipo: "CPF", re: /\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/g, valida: cpfValido },
 { tipo: "TELEFONE", re: /(?:\+?55\s?)?\(?\b\d{2}\)?\s?9?\d{4}[-\s]?\d{4}\b/g },
 { tipo: "CEP", re: /\b\d{5}-\d{3}\b/g },
];

export function detectarPorPadrao(texto: string): Achado[] {
 const achados: Achado[] = [];
 let resto = texto;
 for (const { tipo, re, valida } of REGRAS) {
 for (const m of resto.matchAll(re)) {
 const valor = m[0].trim();
 if (valida && !valida(valor)) continue;
 achados.push({ tipo, valor });
 // tira o trecho já classificado para a próxima regra não reclassificar
 resto = resto.replace(valor, " ".repeat(valor.length));
 }
 }
 return achados;
}

src/cofre.ts

// Pseudonimização reversível: cada dado real vira um token estável ([PESSOA_1],
// [CPF_1]...). O mapa token -> valor real fica só aqui, do seu lado. A IA
// trabalha com os tokens e a resposta é reidentificada no final.

import type { Achado } from "./camada1-regex.ts";

export class Cofre {
 private porValor = new Map<string, string>();
 private porToken = new Map<string, string>();
 private contadores = new Map<string, number>();

 tokenPara({ tipo, valor }: Achado): string {
 // mesma pessoa citada duas vezes precisa virar o mesmo token,
 // senão a IA acha que são duas pessoas diferentes
 const chave = `${tipo}:${valor.toLowerCase()}`;
 const existente = this.porValor.get(chave);
 if (existente) return existente;
 const n = (this.contadores.get(tipo) ?? 0) + 1;
 this.contadores.set(tipo, n);
 const token = `[${tipo}_${n}]`;
 this.porValor.set(chave, token);
 this.porToken.set(token, valor);
 return token;
 }

 substituir(texto: string, achados: Achado[]): string {
 // maiores primeiro: "Maria Souza" antes de "Maria"
 const ordenados = [...achados].sort((a, b) => b.valor.length - a.valor.length);
 let saida = texto;
 for (const a of ordenados) saida = saida.split(a.valor).join(this.tokenPara(a));
 return saida;
 }

 reidentificar(texto: string): string {
 return texto.replace(/\[[A-Z_]+_\d+\]/g, (t) => this.porToken.get(t) ?? t);
 }

 get tamanho(): number {
 return this.porToken.size;
 }
}

src/camada2-claude.ts

// Camada 2: o que regex não pega. Nome próprio, endereço escrito por extenso,
// menção a doença, parentesco que identifica ("a filha do síndico do bloco C").
// O Claude recebe o texto JÁ sem CPF, e-mail, telefone e cartão (camada 1)
// e devolve só a lista do que ainda identifica alguém. Quem troca é o nosso código.

import Anthropic from "@anthropic-ai/sdk";
import { betaZodOutputFormat } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
import type { Achado } from "./camada1-regex.ts";

const Entidades = z.object({
 entidades: z.array(
 z.object({
 tipo: z.enum(["PESSOA", "ENDERECO", "SAUDE", "DATA_NASCIMENTO", "IDENTIFICADOR_INDIRETO"]),
 // o trecho exatamente como aparece no texto, para o replace local funcionar
 valor: z.string(),
 }),
 ),
});

const INSTRUCOES = `Você é um detector de dados pessoais (LGPD) em textos em português.
Liste cada trecho que identifica ou torna identificável uma pessoa natural:
- PESSOA: nome ou apelido de pessoa (não de empresa, produto ou cidade).
- ENDERECO: rua, número, complemento, bairro quando vier junto de um endereço.
- SAUDE: diagnóstico, doença, medicamento, exame ou condição de saúde (dado sensível, art. 5º, II).
- DATA_NASCIMENTO: data de nascimento ou idade exata junto de outro dado.
- IDENTIFICADOR_INDIRETO: descrição que aponta para uma pessoa só ("o único diretor financeiro da filial de Itu").
Copie o trecho exatamente como está no texto, sem corrigir grafia.
Tokens entre colchetes como [CPF_1] já foram anonimizados: ignore.
Na dúvida, inclua. Deixar passar um dado custa mais do que mascarar a mais.`;

const client = new Anthropic();

export async function detectarPorContexto(texto: string): Promise<Achado[]> {
 const resposta = await client.beta.messages.parse({
 model: "claude-opus-5",
 max_tokens: 16000,
 output_config: { effort: "low", format: betaZodOutputFormat(Entidades) },
 // se o modelo principal recusar, a própria API tenta o fallback padrão
 betas: ["server-side-fallback-2026-07-01"],
 fallbacks: "default",
 system: INSTRUCOES,
 messages: [{ role: "user", content: texto }],
 });

 if (resposta.stop_reason === "refusal") {
 throw new Error("Claude recusou a análise; o texto não deve seguir adiante sem revisão.");
 }
 if (!resposta.parsed_output) {
 throw new Error(`resposta sem JSON válido (stop_reason: ${resposta.stop_reason})`);
 }
 // só aceita trecho que existe de verdade no texto: o replace é exato
 return resposta.parsed_output.entidades.filter((e) => texto.includes(e.valor));
}

src/anonimizar.ts

import { detectarPorPadrao, type Achado } from "./camada1-regex.ts";
import { Cofre } from "./cofre.ts";

export type Detector = (texto: string) => Promise<Achado[]>;

export async function anonimizar(texto: string, cofre: Cofre, contexto?: Detector) {
 // 1. local: o que tem formato fixo nunca sai da máquina
 const porPadrao = detectarPorPadrao(texto);
 const etapa1 = cofre.substituir(texto, porPadrao);

 // 2. IA: só vê o texto já sem os identificadores diretos
 const porContexto = contexto ? await contexto(etapa1) : [];
 const final = cofre.substituir(etapa1, porContexto);

 return { texto: final, achados: [...porPadrao, ...porContexto] };
}

// Última barreira: se qualquer valor detectado ainda aparece no texto final,
// o pipeline para. Melhor não processar do que vazar.
export function garantirSemVazamento(saida: string, achados: Achado[]): void {
 const vazou = achados.filter((a) => saida.includes(a.valor));
 if (vazou.length) {
 throw new Error(`vazamento: ${vazou.map((a) => a.tipo).join(", ")}`);
 }
}

src/demo.ts

// npm run demo:local -> só a camada 1 (não chama a API)
// npm run demo -> camadas 1 e 2 + resumo do chamado feito pelo Claude
// Todos os dados abaixo são fictícios (CPF, CNPJ e cartão de teste).

import { anonimizar, garantirSemVazamento } from "./anonimizar.ts";
import { Cofre } from "./cofre.ts";

const CHAMADO = `Oi, aqui é a Mariana Albuquerque, CPF 529.982.247-25.
Meu cartão 4111 1111 1111 1111 foi cobrado duas vezes no pedido 88213.
Moro na Rua das Acácias, 120, apto 34, CEP 01310-100. Pode ligar no (11) 98765-4321
ou mandar para mariana.alb@email.com. Estou com pressa porque faço hemodiálise
toda terça e quarta. A empresa é a Albuquerque Doces, CNPJ 11.222.333/0001-81.`;

const semClaude = process.argv.includes("--sem-claude");
const cofre = new Cofre();

const detector = semClaude ? undefined : (await import("./camada2-claude.ts")).detectarPorContexto;
const { texto, achados } = await anonimizar(CHAMADO, cofre, detector);
garantirSemVazamento(texto, achados);

console.log("── texto que sai da máquina ──────────────────────────");
console.log(texto);
console.log(`\n── cofre local: ${cofre.tamanho} dados guardados, nenhum enviado ──`);
for (const a of achados) console.log(`${a.tipo.padEnd(24)} ${cofre.tokenPara(a)}`);

if (!semClaude) {
 const { default: Anthropic } = await import("@anthropic-ai/sdk");
 const client = new Anthropic();
 const resposta = await client.messages.create({
 model: "claude-opus-5",
 max_tokens: 16000,
 system: "Você é do suporte. Resuma o chamado em 3 linhas e sugira a próxima ação. Mantenha os tokens entre colchetes exatamente como estão.",
 messages: [{ role: "user", content: texto }],
 });
 const resumo = resposta.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");

 console.log("\n── resposta do Claude (só viu tokens) ────────────────");
 console.log(resumo);
 console.log("\n── reidentificada, só para o atendente ───────────────");
 console.log(cofre.reidentificar(resumo));
}

test/anonimizacao.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import { detectarPorPadrao } from "../src/camada1-regex.ts";
import { Cofre } from "../src/cofre.ts";
import { anonimizar, garantirSemVazamento } from "../src/anonimizar.ts";

test("CPF com dígito válido é detectado, número qualquer não", () => {
 const achados = detectarPorPadrao("CPF 529.982.247-25, protocolo 123.456.789-00");
 assert.deepEqual(achados, [{ tipo: "CPF", valor: "529.982.247-25" }]);
});

test("cartão passa no Luhn e não vira CPF nem telefone", () => {
 const achados = detectarPorPadrao("cartão 4111 1111 1111 1111");
 assert.deepEqual(achados.map((a) => a.tipo), ["CARTAO"]);
});

test("CNPJ não é confundido com CPF", () => {
 const achados = detectarPorPadrao("CNPJ 11.222.333/0001-81");
 assert.deepEqual(achados.map((a) => a.tipo), ["CNPJ"]);
});

test("mesma pessoa recebe o mesmo token e a volta é exata", () => {
 const cofre = new Cofre();
 const pessoa = { tipo: "PESSOA", valor: "Mariana Albuquerque" };
 const t = cofre.substituir("Mariana Albuquerque ligou. Mariana Albuquerque aguarda.", [pessoa]);
 assert.equal(t, "[PESSOA_1] ligou. [PESSOA_1] aguarda.");
 assert.equal(cofre.reidentificar(t), "Mariana Albuquerque ligou. Mariana Albuquerque aguarda.");
});

test("detector de contexto falso: nome e saúde saem, nada vaza", async () => {
 const falsoClaude = async () => [
 { tipo: "PESSOA", valor: "Mariana" },
 { tipo: "SAUDE", valor: "hemodiálise" },
 ];
 const cofre = new Cofre();
 const { texto, achados } = await anonimizar(
 "Mariana (mariana@x.com) faz hemodiálise.",
 cofre,
 falsoClaude,
 );
 assert.equal(texto, "[PESSOA_1] ([EMAIL_1]) faz [SAUDE_1].");
 garantirSemVazamento(texto, achados);
});

test("garantirSemVazamento trava quando sobra dado no texto", () => {
 assert.throws(
 () => garantirSemVazamento("ligar para Mariana", [{ tipo: "PESSOA", valor: "Mariana" }]),
 /vazamento: PESSOA/,
 );
});

Referências

  • Lei nº 13.709/2018 (LGPD), arts. 5º, 12 e 13
  • Documentação da API do Claude: saída estruturada (output_config.format) e fallbacks
  • Documentação do Claude Code: permissões (permissions.deny) e CLAUDE.md

Steply. Engenharia com IA, segurança e previsibilidade. steply.com.br